mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-21 09:02:24 +02:00
feat: route health history, compact cards, clear_threshold rename
- Add per-route health history endpoint (GET /routes/{id}/history)
- Optimize history fetch with _fetch_matching_hops (SQL-level prefix filter)
- Add standalone received_at index on packet_path_hops
- Fix cache key collision by including URL path in _routes_key_builder
- Fix Chart.js canvas reuse with Chart.getChart() guard
- Remove fleet overview endpoint (502 timeout on large datasets)
- Redesign route cards: always expanded, compact icon stats row,
per-segment date labels, loading spinner, bottom-pinned admin buttons
- Rename degraded_threshold -> clear_threshold across model, schema,
API, metrics, CLI, frontend, tests, and seed config
- Add iconClock to icons.js
- Update AGENTS.md: random revision IDs, never build images
- Two Alembic migrations: received_at index, column rename
This commit is contained in:
@@ -11,6 +11,8 @@
|
||||
- Use Python (version in `.python-version`); activate a venv in `.venv` before running pytest, pre-commit, or alembic locally.
|
||||
- **All other operations run inside the compose stack** — never invoke `meshcore-hub` or `npm` directly on the host; build/run/exec via `docker compose` (see Development).
|
||||
- **Never `git push` without explicit confirmation** — staging and committing discrete changes is fine.
|
||||
- **Never build the Docker images or run `make build` / `make up`** — the user builds manually to test. Stop after code changes + tests + pre-commit pass.
|
||||
- **Always generate random Alembic revision IDs** — use `python -c "import secrets; print(secrets.token_hex(6))"` or let `alembic revision` auto-generate. Never hand-pick sequential or guessable IDs like `a1b2c3d4e5f6` — they collide with existing migrations and cause cycle errors at upgrade time.
|
||||
- Before committing: run targeted `pytest --no-cov tests/test_<component>/` then `pre-commit run --all-files`.
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""add standalone received_at index to packet_path_hops
|
||||
|
||||
Revision ID: 76513f4c57e9
|
||||
Revises: c0d1e2f3a4b5
|
||||
Create Date: 2026-07-15 21:00:00.000000+00:00
|
||||
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "76513f4c57e9"
|
||||
down_revision: Union[str, None] = "c0d1e2f3a4b5"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_index(
|
||||
"ix_packet_path_hops_received_at",
|
||||
"packet_path_hops",
|
||||
["received_at"],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(
|
||||
"ix_packet_path_hops_received_at",
|
||||
table_name="packet_path_hops",
|
||||
)
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Rename degraded_threshold to clear_threshold.
|
||||
|
||||
Revision ID: 71f6e01e4bf6
|
||||
Revises: 76513f4c57e9
|
||||
Create Date: 2026-07-15 23:00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "71f6e01e4bf6"
|
||||
down_revision = "76513f4c57e9"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column(
|
||||
"routes",
|
||||
"degraded_threshold",
|
||||
new_column_name="clear_threshold",
|
||||
)
|
||||
op.alter_column(
|
||||
"route_results",
|
||||
"effective_degraded",
|
||||
new_column_name="effective_clear",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.alter_column(
|
||||
"route_results",
|
||||
"effective_clear",
|
||||
new_column_name="effective_degraded",
|
||||
)
|
||||
op.alter_column(
|
||||
"routes",
|
||||
"clear_threshold",
|
||||
new_column_name="degraded_threshold",
|
||||
)
|
||||
@@ -0,0 +1,569 @@
|
||||
# Route Health History (7-Day Charts)
|
||||
|
||||
## Summary
|
||||
|
||||
Add two Chart.js visualizations of route health over the last 7 days, backed by
|
||||
**compute-on-read** re-evaluation of the existing matching engine against
|
||||
`packet_path_hops`. The Routes overview page gains a **fleet distribution
|
||||
chart** — vertical stacked bars per day, each bar showing how many routes were
|
||||
in each quality band (clear / marginal / failing / no_coverage / disabled).
|
||||
The per-route detail expand gains a **status strip** — a single horizontal bar
|
||||
split into 7 day-segments, each colored by that day's quality band (green /
|
||||
amber / red / grey), like an uptime-monitor status bar.
|
||||
|
||||
No new database tables or migrations are required. Health history is
|
||||
reconstructed on demand by re-running the existing `evaluate_route` matching
|
||||
logic per calendar day over the retained `packet_path_hops` rows. The history
|
||||
horizon therefore tracks `effective_raw_packet_retention_days` automatically (default 7)
|
||||
— bumping that setting widens the chart's available window with no code change.
|
||||
Both endpoints are Redis-cached behind the same `redis_cache_ttl_dashboard` TTL
|
||||
used by the dashboard timeseries endpoints, and the entire Chart.js color/date
|
||||
infrastructure already in `charts.js` is reused.
|
||||
|
||||
## Background & Motivation
|
||||
|
||||
The Routes feature (`20260705-2306-mesh-link-monitoring`, shipped across commits
|
||||
`14fbc45` → `ae22dff`) introduced the `Route` entity, the `packet_path_hops`
|
||||
ingest index, the `evaluate_route` matching engine, the 60-second background
|
||||
evaluator, and the `/routes` status-board page. **Health is, however, strictly
|
||||
point-in-time:** the evaluator upserts a single `RouteResult` row per route in
|
||||
place (`upsert_route_result`, `collector/routes.py:415`), overwriting
|
||||
`state`/`quality`/`matched_count` every cycle. Past values are lost — the
|
||||
feature's own Non-Goals listed "historical route-health time series" as future
|
||||
work.
|
||||
|
||||
Operators can see *right now* whether a route is clear/marginal/failing, and
|
||||
Prometheus exposes the current gauges for external alerting, but **the UI gives
|
||||
no sense of how a route (or the fleet) has been trending over the week.** A
|
||||
route that flapped red overnight and recovered shows only green today. This
|
||||
plan closes that gap.
|
||||
|
||||
The key enabler is that **evaluation is a pure, deterministic query over
|
||||
`packet_path_hops`** (`evaluate_route`, `collector/routes.py:341`), and those
|
||||
hop rows are retained for 7 days by default (`RAW_PACKET_RETENTION_DAYS=7`,
|
||||
cascade-deleted with `raw_packets` per `cleanup.py:118-121`). Re-running the
|
||||
matcher per calendar day reconstructs the daily quality band without storing
|
||||
anything new. The compute cost is trivial relative to existing load — the
|
||||
background evaluator already runs N evaluations every 60 s (~1440N/day); a 7-day
|
||||
on-demand chart is 7N evaluations served behind the Redis cache.
|
||||
|
||||
The dashboard packet-breakdown charts (`20260704-1429-packet-breakdown-charts`,
|
||||
shipped as `createStackedBarChart` in `charts.js:243`) established the exact
|
||||
patterns this plan mirrors: a cached aggregation endpoint, a `BreakdownBucket`-
|
||||
style schema, and a stacked-bar Chart.js helper.
|
||||
|
||||
## Goals
|
||||
|
||||
- Surface 7-day route health **without a migration or new persistence** —
|
||||
compute-on-read over the existing `packet_path_hops` index.
|
||||
- Add a **fleet distribution chart** on the Routes overview: vertical stacked
|
||||
bars per day, x-axis = 7 dates, segments = count of routes per quality band.
|
||||
- Add a **per-route status strip** in the detail expand: a single horizontal
|
||||
bar, 7 day-segments, each colored by that day's quality band (the uptime-bar
|
||||
idiom).
|
||||
- Make the history horizon **track `RAW_PACKET_RETENTION_DAYS` automatically**
|
||||
so operators who raise retention get a wider window for free.
|
||||
- Reuse the existing Chart.js v4 vendoring, the `ChartColors`/date infrastructure
|
||||
in `charts.js`, and the `@cached` dashboard-cache pattern — no new frontend
|
||||
dependencies and no new caching plumbing.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- **No `route_result_history` table.** Persisted history is explicitly deferred
|
||||
to a future plan. Compute-on-read is capped at the raw-packet retention window
|
||||
(default 7 days); if operators later need longer history, the persist-history
|
||||
table is the follow-on (the matching engine is already structured to append).
|
||||
- **No range picker.** The window is fixed at 7 days. A configurable 7/14/30-day
|
||||
selector only becomes meaningful once the persist-history table exists (since
|
||||
compute-on-read cannot exceed `RAW_PACKET_RETENTION_DAYS`).
|
||||
- **No changes to the 60-second evaluator hot path.** History is computed by a
|
||||
sibling function; `evaluate_route` and `upsert_route_result` are untouched.
|
||||
- **No Prometheus changes.** The existing `meshcore_route_quality` /
|
||||
`meshcore_route_healthy` gauges already expose current state; this plan adds
|
||||
no new metrics (history is a UI concern, not an alerting one).
|
||||
- **No sub-day granularity.** One quality band per day per route. Finer buckets
|
||||
(4h/3h) were considered and rejected — they multiply the evaluation cost and
|
||||
add visual noise without changing the alerting story.
|
||||
- **No seeding or config changes** — the feature reads only existing route
|
||||
configuration and hop data.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-1 — Fleet history endpoint.** `GET /api/v1/routes/history?days=7` returns
|
||||
a `RouteFleetHistory` payload: one `RouteFleetDayPoint` per calendar day in
|
||||
the window, each carrying per-band counts of routes:
|
||||
```json
|
||||
{
|
||||
"days": 7,
|
||||
"data": [
|
||||
{ "date": "2026-07-09", "clear": 4, "marginal": 1, "failing": 0,
|
||||
"no_coverage": 1, "disabled": 2 },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
Each enabled route contributes to exactly one band per day based on its
|
||||
`RouteQuality` (clear / marginal / failing / unknown — the `unknown` band is
|
||||
labelled "no_coverage" in the chart, matching the existing UI at
|
||||
`routes.js:51-68`). Disabled routes contribute to the `disabled` counter for
|
||||
every day and are **not** evaluated.
|
||||
|
||||
- **FR-2 — Per-route history endpoint.** `GET /api/v1/routes/{route_id}/history
|
||||
?days=7` returns a `RouteHistory` payload — one `RouteDayQuality` per day:
|
||||
```json
|
||||
{
|
||||
"route_id": "...",
|
||||
"days": 7,
|
||||
"data": [
|
||||
{ "date": "2026-07-09", "quality": "clear", "state": "healthy",
|
||||
"matched_count": 12 },
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
The `quality` value is one of the `RouteQuality` enum values: `clear` /
|
||||
`marginal` / `failing` / `unknown`. For a disabled route, every day's
|
||||
`state` is `no_coverage` and `quality` is `unknown` (consistent with how the
|
||||
list endpoint renders disabled routes).
|
||||
|
||||
- **FR-3 — Window semantics.** `days` defaults to 7 and is **clamped to
|
||||
`settings.effective_raw_packet_retention_days`** so the endpoint never claims
|
||||
a horizon the retained hop data cannot satisfy. The oldest complete day is
|
||||
`today - days`; each day-bucket covers `[00:00, next 00:00)` UTC. The fleet
|
||||
overview chart **excludes today** (partial day, consistent with dashboard
|
||||
daily-activity charts at `dashboard.py:462-467`); the per-route detail strip
|
||||
**includes today** as its final segment (route health is a live concern —
|
||||
seeing the current partial-day state is the point) labeled with the locale's
|
||||
"today".
|
||||
|
||||
- **FR-4 — Visibility scoping.** Both endpoints are role-scoped exactly like the
|
||||
existing route endpoints (`VISIBILITY_LEVELS` + `get_max_visibility_level`,
|
||||
per `api/routes/routes.py:226-229`). The fleet endpoint counts only routes
|
||||
the caller may read; the per-route endpoint returns 404 for routes above the
|
||||
caller's level (matching `get_route`'s posture). Both are guarded by
|
||||
`RequireRead`.
|
||||
|
||||
- **FR-5 — Caching.** Both endpoints are Redis-cached under
|
||||
`redis_cache_ttl_dashboard` via the existing `@cached` decorator, keyed by the
|
||||
existing `_routes_key_builder` (role + sorted query string, at
|
||||
`api/routes/routes.py:44`) so the per-role visibility scoping is part of the
|
||||
cache key.
|
||||
|
||||
- **FR-6 — Fleet chart (overview).** On the `/routes` page, when
|
||||
`features.routes !== false`, a chart card renders **vertical stacked bars**:
|
||||
x-axis = the 7 day labels, one dataset per band (clear / marginal / failing /
|
||||
no_coverage / disabled), `scales.x.stacked = true` and
|
||||
`scales.y.stacked = true`, `y.beginAtZero = true`. Legend lists the bands in
|
||||
fixed semantic order. Tooltip (index mode) lists the non-zero bands for the
|
||||
hovered day with their counts.
|
||||
|
||||
- **FR-7 — Status strip (detail).** In the route card expand, a chart renders
|
||||
**one horizontal bar divided into 7 equal day-segments**, each segment colored
|
||||
by that day's `quality` band (clear=green, marginal=amber, failing=red,
|
||||
no_coverage/unknown=grey). The strip has a date axis beneath it (oldest →
|
||||
newest, left → right) and today's segment labeled. Tooltip per segment shows
|
||||
the date, the quality label, and `matched_count`.
|
||||
|
||||
- **FR-8 — Empty / no-data handling.** If no routes are visible to the caller,
|
||||
the fleet chart card renders with a blank canvas and a "no routes" state
|
||||
(mirroring `createLineChart`'s empty-data early return at `charts.js:155`).
|
||||
If a single route has no retained hops for the full window (e.g. created
|
||||
recently), its strip renders with all segments in the `unknown` band color.
|
||||
|
||||
- **FR-9 — Feature gating.** Both the frontend charts and the data fetch are
|
||||
gated on `features.routes !== false` (client-side, the same idiom the routes
|
||||
page nav/route registration uses in `app.js`). The endpoints themselves carry
|
||||
only `RequireRead` (no server-side feature flag) — consistent with how the
|
||||
dashboard breakdown endpoint relates to `feature_packets`.
|
||||
|
||||
### Technical Requirements
|
||||
|
||||
- **TR-1 — Day-bounded evaluation helper.** Add
|
||||
`evaluate_route_day(session, route, day_start, day_end) -> tuple[str, str,
|
||||
int]` to `collector/routes.py` as a sibling of `evaluate_route`
|
||||
(`collector/routes.py:341`). It mirrors `evaluate_route` exactly but bounds
|
||||
the candidate fetch to `received_at >= day_start AND received_at < day_end`
|
||||
(the current function only takes a `since`). Reuse the existing private
|
||||
helpers unchanged: `_route_expected_hashes(route)`,
|
||||
`fetch_candidate_paths(...)` (extended with an optional `until` param that
|
||||
appends `PacketPathHop.received_at < until` to the subquery at line 262 —
|
||||
the composite index `ix_packet_path_hops_node_hash_received_at` covers this
|
||||
range scan), `_fetch_candidate_paths_maybe_bidirectional(...)` (extended to
|
||||
pass `until` through to `fetch_candidate_paths`), `_match_hops(...)`,
|
||||
`effective_degraded_threshold(route)`, `derive_quality(state, matched_count,
|
||||
threshold, effective_degraded)` (`routes.py:61`), and
|
||||
`_has_any_hops_in_window(...)` (extended with `until`). Implementing it as a
|
||||
sibling (not a flag on `evaluate_route`) keeps the 60-second hot path
|
||||
untouched and avoids regressions in the shipped evaluator.
|
||||
|
||||
- **TR-2 — History batch helper.** Add
|
||||
`evaluate_route_history(session, route, days, *, include_today=False) ->
|
||||
list[tuple[date, str, str, int]]` to `collector/routes.py`. It computes the
|
||||
day boundaries (UTC midnight buckets, oldest first) and calls
|
||||
`evaluate_route_day` per day. For a disabled route it returns
|
||||
`(date, RouteQuality.UNKNOWN.value, RouteState.NO_COVERAGE.value, 0)`
|
||||
for every day without hitting the DB. This is the single function both
|
||||
endpoints call.
|
||||
|
||||
- **TR-3 — Schemas.** Add to `common/schemas/routes.py` (mirroring the
|
||||
`DailyActivity` / `BreakdownBucket` idioms in `common/schemas/messages.py`):
|
||||
```python
|
||||
class RouteDayQuality(BaseModel):
|
||||
date: date
|
||||
quality: str
|
||||
state: str
|
||||
matched_count: int
|
||||
|
||||
class RouteHistory(BaseModel):
|
||||
route_id: str
|
||||
days: int
|
||||
data: list[RouteDayQuality]
|
||||
|
||||
class RouteFleetDayPoint(BaseModel):
|
||||
date: date
|
||||
clear: int = 0
|
||||
marginal: int = 0
|
||||
failing: int = 0
|
||||
no_coverage: int = 0
|
||||
disabled: int = 0
|
||||
|
||||
class RouteFleetHistory(BaseModel):
|
||||
days: int
|
||||
data: list[RouteFleetDayPoint]
|
||||
```
|
||||
Use `datetime.date` (not `datetime`) for `date` fields so payloads serialize
|
||||
as `YYYY-MM-DD` with no time component. The `no_coverage` field aggregates
|
||||
all routes whose quality is `unknown` — matching the existing UI's label
|
||||
mapping at `routes.js:51-68`.
|
||||
|
||||
- **TR-4 — Fleet endpoint.** Add to `api/routes/routes.py` on the existing
|
||||
router (already mounted at `/api/v1/routes`):
|
||||
```python
|
||||
@router.get("/history", response_model=RouteFleetHistory)
|
||||
@cached("routes/history", ttl_setting="redis_cache_ttl_dashboard",
|
||||
key_builder=_routes_key_builder)
|
||||
def get_route_fleet_history(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
request: Request,
|
||||
days: int = 7,
|
||||
) -> RouteFleetHistory:
|
||||
```
|
||||
Load the routes visible to the caller's role (reuse the visibility filter
|
||||
from `get_routes`). Clamp `days = min(days, retention_days)` where
|
||||
`retention_days` comes from `settings.effective_raw_packet_retention_days`.
|
||||
For each enabled route call `evaluate_route_history(session, route, days,
|
||||
include_today=False)`; bucket each day's quality into the band counters
|
||||
(quality `unknown` → `no_coverage`, matching the existing UI mapping at
|
||||
`routes.js:51-68`). Disabled routes add to `disabled` for every day.
|
||||
Return oldest-day-first.
|
||||
**Route ordering note:** this endpoint must be declared **before** the
|
||||
`/{route_id}` path routes so FastAPI does not match `history` as a
|
||||
`route_id` path parameter.
|
||||
|
||||
- **TR-5 — Per-route endpoint.** Add to `api/routes/routes.py`:
|
||||
```python
|
||||
@router.get("/{route_id}/history", response_model=RouteHistory)
|
||||
@cached("routes/{id}/history", ttl_setting="redis_cache_ttl_dashboard",
|
||||
key_builder=_routes_key_builder)
|
||||
def get_route_history(
|
||||
_: RequireRead,
|
||||
session: DbSession,
|
||||
route_id: str,
|
||||
request: Request,
|
||||
days: int = 7,
|
||||
) -> RouteHistory:
|
||||
```
|
||||
Fetch the route, apply the visibility check (mirror `get_route` at lines
|
||||
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: <date>, data: [1], backgroundColor:
|
||||
ChartColors.quality[day.quality]() }`. With `scales.x.stacked = true` and
|
||||
`scales.y.stacked = true`, the seven unit-width datasets render as one bar
|
||||
split into seven equal colored segments.
|
||||
- Hide both axes' ticks; render a date-axis row beneath the canvas via a
|
||||
separate labels element (HTML, not Chart.js) so the dates align under each
|
||||
segment without Chart.js category-axis clutter.
|
||||
- Tooltip per segment: date + quality label + `matched_count`.
|
||||
- Return `null` when `routeData` is empty.
|
||||
|
||||
- **TR-9 — Overview wiring.** In `spa/pages/routes.js`, add a chart card
|
||||
containing `<canvas id="routeOverviewChart">` adjacent to the existing
|
||||
summary strip (`renderSummaryStrip`, `routes.js:51`). After the routes list
|
||||
loads in `renderPage`, fire `apiGet('/api/v1/routes/history', { days: 7 },
|
||||
{ signal })` and call `createRouteOverviewChart`. Manage the Chart.js
|
||||
instance lifecycle — create a `chartIds` array and return a cleanup function
|
||||
that destroys each instance on page unmount, mirroring the dashboard page's
|
||||
chart cleanup pattern at `dashboard.js:324-333`. Include abort support via
|
||||
`{ signal }` for fetch cancellation on page unmount.
|
||||
|
||||
- **TR-10 — Detail wiring.** In `spa/pages/routes.js`, inside the expanded-card
|
||||
detail content (`renderDetailContent`), add a `<canvas>` and fetch
|
||||
`/api/v1/routes/${route.id}/history?days=7`, then call
|
||||
`createRouteDetailStrip`. Render only when the card is expanded, matching the
|
||||
existing lazy-load pattern used for the `detail` payload. Destroy the strip
|
||||
instance when the card collapses.
|
||||
|
||||
- **TR-11 — i18n.** Add keys under the existing `routes.*` block to both
|
||||
`src/meshcore_hub/web/static/locales/en.json` and `nl.json`:
|
||||
- `routes.history_title` — "Health (last 7 days)" / "Gezondheid (laatste 7
|
||||
dagen)" (fleet chart card title).
|
||||
- `routes.history_detail_title` — "Last 7 days" / "Laatste 7 dagen" (strip
|
||||
label).
|
||||
- `routes.history_today` — "Today" / "Vandaag" (the final partial segment
|
||||
label on the detail strip).
|
||||
- The band labels (`routes.quality_clear`, `routes.quality_marginal`,
|
||||
`routes.quality_failing`, `routes.quality_no_coverage`,
|
||||
`routes.quality_unknown`, `routes.disabled`) already exist at
|
||||
`routes.js:24-34` and are reused for the legend and tooltips.
|
||||
|
||||
- **TR-12 — Backend agnosticism.** All queries use SQLAlchemy Core/ORM with
|
||||
Python-computed day boundaries (`datetime` arithmetic, never `NOW() -
|
||||
INTERVAL`), consistent with the backend-agnostic convention established by the
|
||||
Routes plan (T6). The day-bounded fetch is a simple `received_at >= day_start
|
||||
AND received_at < day_end` range — sargable on both SQLite and Postgres.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Day-bounded matching engine + tests
|
||||
|
||||
- **`src/meshcore_hub/collector/routes.py`**:
|
||||
- Extend `_fetch_candidate_paths_maybe_bidirectional` and
|
||||
`_has_any_hops_in_window` with an optional `until: Optional[datetime]`
|
||||
parameter that appends `PacketPathHop.received_at < until` to the existing
|
||||
`received_at >= since` filter. The composite index
|
||||
`ix_packet_path_hops_node_hash_received_at` covers the bounded range scan.
|
||||
- Add `evaluate_route_day(session, route, day_start, day_end)` per TR-1 — a
|
||||
line-for-line mirror of `evaluate_route` (`routes.py:341-389`) but calling
|
||||
the bounded fetch helpers. Reuses `_route_expected_hashes`,
|
||||
`effective_degraded_threshold`, `_match_hops`, `derive_quality` unchanged.
|
||||
- Add `evaluate_route_history(session, route, days, *, include_today=False)`
|
||||
per TR-2.
|
||||
- **Tests** — extend `tests/test_collector/test_routes.py`:
|
||||
- `evaluate_route_day` returns the correct band for clear / marginal /
|
||||
failing / no_coverage (seed hops inside vs. outside the day window).
|
||||
- Day boundaries are strict: hops in the adjacent day do not leak across the
|
||||
`day_end` bound.
|
||||
- `evaluate_route_history` returns `days` entries oldest-first, with the
|
||||
correct `include_today` behavior (one extra partial-day entry when true).
|
||||
- A disabled route returns `unknown`/`no_coverage`/`0` for every day without
|
||||
a DB hit.
|
||||
|
||||
### Phase 2: Schemas + API endpoints + tests
|
||||
|
||||
- **`src/meshcore_hub/common/schemas/routes.py`**: add `RouteDayQuality`,
|
||||
`RouteHistory`, `RouteFleetDayPoint`, `RouteFleetHistory` per TR-3.
|
||||
- **`src/meshcore_hub/api/routes/routes.py`**:
|
||||
- Add `GET /history` (fleet) per TR-4. **Declare it before the `/{route_id}`
|
||||
routes** (FastAPI matches path patterns in declaration order; without this,
|
||||
`history` would be captured as a `route_id`).
|
||||
- Add `GET /{route_id}/history` (per-route) per TR-5.
|
||||
- Import `evaluate_route_history` from `collector.routes`; import the new
|
||||
schemas; read `raw_packet_retention_days` from settings for the `days` clamp.
|
||||
- Visibility filtering reuses `VISIBILITY_LEVELS` / `get_max_visibility_level`
|
||||
/ `resolve_user_role` already imported at the top of the file.
|
||||
- **Tests** — `tests/test_api/test_routes.py`:
|
||||
- Fleet endpoint: response shape, per-day band counts sum to the visible-route
|
||||
count, disabled routes count only to `disabled`, visibility filtering (a
|
||||
low-role caller does not see admin-only routes in the counts), `days` clamp
|
||||
to `RAW_PACKET_RETENTION_DAYS`, caching (role-keyed).
|
||||
- Per-route endpoint: per-day quality/state/matched_count shape, 404 for a
|
||||
hidden route, 404 for an unknown route, includes today as the final segment.
|
||||
- Route-ordering guard: confirm `GET /history` is not shadowed by
|
||||
`GET /{route_id}`.
|
||||
|
||||
### Phase 3: Chart helpers + quality palette
|
||||
|
||||
- **`src/meshcore_hub/web/static/js/charts.js`**:
|
||||
- Add the `quality` color map to `ChartColors` (TR-6), using hardcoded oklch
|
||||
values (same approach as the `breakdown` palette at lines 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).
|
||||
@@ -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: <date>, data: [1], backgroundColor: ChartColors.quality[day.quality] }`
|
||||
- [x] `scales.x.stacked = true`, `scales.y.stacked = true` so seven unit-width datasets render as one bar with seven equal colored segments
|
||||
- [x] Hide both axes' ticks
|
||||
- [x] Render a date-axis row beneath the canvas via HTML (not Chart.js) so dates align under each segment — fallback: Chart.js category x-axis with `ticks.maxRotation: 0`, `maxTicksLimit: 7`
|
||||
- [x] Tooltip per segment: date + quality label + `matched_count`
|
||||
- [x] Return `null` when `routeData` is empty
|
||||
- [ ] Manual check: verify both helpers render correctly from browser console with mock data
|
||||
- [ ] `make up`, then call helpers from console with sample payloads
|
||||
- [ ] Confirm stacked scales, segment colors, tooltips before SPA wiring
|
||||
|
||||
## Phase 4: SPA Wiring + i18n
|
||||
|
||||
- [x] Add i18n keys to `en.json` and `nl.json` (TR-11)
|
||||
- [x] `routes.history_title` — "Health (last 7 days)" / "Gezondheid (laatste 7 dagen)"
|
||||
- [x] `routes.history_detail_title` — "Last 7 days" / "Laatste 7 dagen"
|
||||
- [x] `routes.history_today` — "Today" / "Vandaag"
|
||||
- [x] Verify existing band labels (`routes.quality_clear`, etc.) are reused for legend/tooltips
|
||||
- [x] Add fleet overview chart to `spa/pages/routes.js` (TR-9)
|
||||
- [x] Add a chart card with `<canvas id="routeOverviewChart">` adjacent to the existing summary strip (`renderSummaryStrip`, line 51)
|
||||
- [x] Gate on `features.routes !== false`
|
||||
- [x] After routes list loads in `renderPage`, fire `apiGet('/api/v1/routes/history', { days: 7 }, { signal })`
|
||||
- [x] Call `createRouteOverviewChart` with the response
|
||||
- [x] Create a `chartIds` array and return a cleanup function that destroys each Chart.js instance on page unmount (mirror `dashboard.js:324-333`)
|
||||
- [x] Include abort support via `{ signal }` for fetch cancellation on page unmount
|
||||
- [x] Add detail status strip to `spa/pages/routes.js` (TR-10)
|
||||
- [x] Inside the expanded-card detail content (`renderDetailContent`), add a `<canvas>` element
|
||||
- [x] Fetch `/api/v1/routes/${route.id}/history?days=7` lazily (only when card is expanded)
|
||||
- [x] Call `createRouteDetailStrip` with the response
|
||||
- [x] Destroy the strip instance when the card collapses
|
||||
- [x] Match the existing lazy-load pattern used for the `detail` payload
|
||||
|
||||
## Phase 5: Verification
|
||||
|
||||
- [x] Run targeted tests
|
||||
- [x] `pytest --no-cov tests/test_collector/test_routes.py`
|
||||
- [x] `pytest --no-cov tests/test_api/test_routes.py`
|
||||
- [x] `pytest --no-cov tests/test_web/`
|
||||
- [x] Run quality checks
|
||||
- [x] `pre-commit run --all-files`
|
||||
- [ ] Rebuild and start the stack
|
||||
- [ ] `make build` (SPA bundle rebuild via Docker pipeline)
|
||||
- [ ] `make up`
|
||||
- [ ] Visual verification on `/routes` page
|
||||
- [ ] Fleet chart renders with 7 stacked bars (one per day)
|
||||
- [ ] Tooltips list non-zero bands per day
|
||||
- [ ] Legend lists bands in fixed semantic order (clear → marginal → failing → no_coverage → disabled)
|
||||
- [ ] Expand a route card: status strip renders 7 colored segments oldest → newest
|
||||
- [ ] Today's segment is labeled on the detail strip
|
||||
- [ ] Tooltips show date + band label + matched_count
|
||||
- [ ] Edge case verification
|
||||
- [ ] Empty state: no visible routes → blank canvas + "no routes" message
|
||||
- [ ] Route with no hops in window → all-grey (no_coverage) strip
|
||||
- [ ] Role scoping: log in as a low-role user, confirm fleet counts and per-route 404 reflect visibility
|
||||
- [ ] Resolve Open Question: "Today" in the overview
|
||||
- [ ] Evaluate whether excluding today from fleet chart but including it on detail strip reads awkwardly
|
||||
- [ ] If so, include today in both (partial-day bars acceptable for a status board)
|
||||
- [ ] Resolve Open Question: Status-strip date axis
|
||||
- [ ] Confirm HTML date row aligns cleanly under segments
|
||||
- [ ] If not, switch to Chart.js category x-axis with `ticks.maxRotation: 0`, `maxTicksLimit: 7`
|
||||
+1
-1
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -6,11 +6,11 @@ per reception. Scales with (candidates) only, not (candidates × depth).
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from meshcore_hub.common.models.node import Node
|
||||
@@ -24,9 +24,9 @@ from meshcore_hub.common.models.route_result import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Multiplier for the relative default comfort bar (``degraded_threshold = None``
|
||||
#: means ``effective_degraded = 2 × packet_count_threshold``).
|
||||
DEGRADED_DEFAULT_MULTIPLIER = 2
|
||||
#: Multiplier for the relative default comfort bar (``clear_threshold = None``
|
||||
#: means ``effective_clear = 2 × packet_count_threshold``).
|
||||
CLEAR_DEFAULT_MULTIPLIER = 2
|
||||
|
||||
#: Cap on candidate receptions for preview to bound work per call.
|
||||
PREVIEW_CANDIDATE_CAP = 5000
|
||||
@@ -51,10 +51,10 @@ def derive_expected_hash(public_key: str, match_width: int) -> str:
|
||||
return public_key[: 2 * match_width].upper()
|
||||
|
||||
|
||||
def effective_degraded_threshold(route: Route) -> int:
|
||||
def effective_clear_threshold(route: Route) -> int:
|
||||
"""The effective comfort bar: explicit value or ``2 × threshold``."""
|
||||
return route.degraded_threshold or (
|
||||
route.packet_count_threshold * DEGRADED_DEFAULT_MULTIPLIER
|
||||
return route.clear_threshold or (
|
||||
route.packet_count_threshold * CLEAR_DEFAULT_MULTIPLIER
|
||||
)
|
||||
|
||||
|
||||
@@ -62,11 +62,11 @@ def derive_quality(
|
||||
state: str,
|
||||
matched_count: int,
|
||||
threshold: int,
|
||||
effective_degraded: int,
|
||||
effective_clear: int,
|
||||
) -> str:
|
||||
"""Map ``(state, matched_count, thresholds)`` to a quality band."""
|
||||
if state == RouteState.HEALTHY.value:
|
||||
if matched_count >= effective_degraded:
|
||||
if matched_count >= effective_clear:
|
||||
return RouteQuality.CLEAR.value
|
||||
return RouteQuality.MARGINAL.value
|
||||
if state == RouteState.UNHEALTHY.value:
|
||||
@@ -175,12 +175,15 @@ def _fetch_candidate_paths_maybe_bidirectional(
|
||||
since: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
reversible: bool = True,
|
||||
until: Optional[datetime] = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Fetch candidate paths from one or both endpoints of *expected*."""
|
||||
paths = fetch_candidate_paths(session, expected[0], since, observer_ids)
|
||||
paths = fetch_candidate_paths(
|
||||
session, expected[0], since, observer_ids, until=until
|
||||
)
|
||||
if reversible and len(expected) > 1 and expected[-1] != expected[0]:
|
||||
for rp_id, hops in fetch_candidate_paths(
|
||||
session, expected[-1], since, observer_ids
|
||||
session, expected[-1], since, observer_ids, until=until
|
||||
).items():
|
||||
if rp_id not in paths:
|
||||
paths[rp_id] = hops
|
||||
@@ -245,6 +248,7 @@ def fetch_candidate_paths(
|
||||
since: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
limit: Optional[int] = None,
|
||||
until: Optional[datetime] = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Fetch all hops for receptions whose path starts with *first_prefix*.
|
||||
|
||||
@@ -263,6 +267,8 @@ def fetch_candidate_paths(
|
||||
)
|
||||
.group_by(PacketPathHop.raw_packet_id)
|
||||
)
|
||||
if until is not None:
|
||||
subq = subq.where(PacketPathHop.received_at < until)
|
||||
if observer_ids:
|
||||
subq = subq.where(PacketPathHop.observer_node_id.in_(observer_ids))
|
||||
if limit is not None:
|
||||
@@ -299,6 +305,66 @@ def fetch_candidate_paths(
|
||||
return paths
|
||||
|
||||
|
||||
def _fetch_matching_hops(
|
||||
session: Session,
|
||||
prefixes: list[str],
|
||||
since: datetime,
|
||||
until: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
"""Fetch only hops whose ``node_hash`` matches any of *prefixes*.
|
||||
|
||||
Unlike :func:`fetch_candidate_paths` (which loads **all** hops for
|
||||
matching raw packets), this returns only the hops whose prefix is in
|
||||
the set. The subsequence matcher (:func:`_subsequence_indices`) only
|
||||
inspects hops whose ``node_hash`` starts with an expected prefix, so
|
||||
filtering at the SQL level is semantically identical but returns far
|
||||
fewer rows.
|
||||
"""
|
||||
prefix_ranges = []
|
||||
for prefix in dict.fromkeys(prefixes):
|
||||
prefix_end = _hex_prefix_end(prefix)
|
||||
prefix_ranges.append(
|
||||
and_(
|
||||
PacketPathHop.node_hash >= prefix,
|
||||
PacketPathHop.node_hash < prefix_end,
|
||||
)
|
||||
)
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
PacketPathHop.raw_packet_id,
|
||||
PacketPathHop.position,
|
||||
PacketPathHop.node_hash,
|
||||
PacketPathHop.packet_hash,
|
||||
PacketPathHop.received_at,
|
||||
)
|
||||
.where(
|
||||
PacketPathHop.received_at >= since,
|
||||
PacketPathHop.received_at < until,
|
||||
or_(*prefix_ranges),
|
||||
)
|
||||
.order_by(PacketPathHop.raw_packet_id, PacketPathHop.position)
|
||||
)
|
||||
if observer_ids:
|
||||
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
|
||||
|
||||
paths: dict[str, list[dict[str, Any]]] = {}
|
||||
for row in session.execute(stmt).all():
|
||||
rp_id = row.raw_packet_id
|
||||
if rp_id not in paths:
|
||||
paths[rp_id] = []
|
||||
paths[rp_id].append(
|
||||
{
|
||||
"position": row.position,
|
||||
"node_hash": row.node_hash,
|
||||
"packet_hash": row.packet_hash,
|
||||
"received_at": row.received_at,
|
||||
}
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def _count_candidate_receptions(
|
||||
session: Session,
|
||||
first_prefix: str,
|
||||
@@ -321,6 +387,7 @@ def _has_any_hops_in_window(
|
||||
session: Session,
|
||||
since: datetime,
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
until: Optional[datetime] = None,
|
||||
) -> bool:
|
||||
"""Existence check: are there ANY in-scope hops in the window?"""
|
||||
stmt = (
|
||||
@@ -328,11 +395,53 @@ def _has_any_hops_in_window(
|
||||
.select_from(PacketPathHop)
|
||||
.where(PacketPathHop.received_at >= since)
|
||||
)
|
||||
if until is not None:
|
||||
stmt = stmt.where(PacketPathHop.received_at < until)
|
||||
if observer_ids:
|
||||
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
|
||||
return (session.execute(stmt).scalar() or 0) > 0
|
||||
|
||||
|
||||
def _has_any_hops_per_day(
|
||||
session: Session,
|
||||
day_starts: list[datetime],
|
||||
day_ends: list[datetime],
|
||||
observer_ids: Optional[list[str]] = None,
|
||||
) -> set[date]:
|
||||
"""Per-day existence check in a single GROUP BY query.
|
||||
|
||||
Returns the set of dates that have at least one hop. Replaces N
|
||||
per-day ``_has_any_hops_in_window`` calls with one query.
|
||||
"""
|
||||
if not day_starts:
|
||||
return set()
|
||||
|
||||
window_start = day_starts[0]
|
||||
window_end = day_ends[-1]
|
||||
|
||||
day_expr = func.date(PacketPathHop.received_at)
|
||||
|
||||
stmt = (
|
||||
select(day_expr)
|
||||
.where(
|
||||
PacketPathHop.received_at >= window_start,
|
||||
PacketPathHop.received_at < window_end,
|
||||
)
|
||||
.group_by(day_expr)
|
||||
)
|
||||
if observer_ids:
|
||||
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
|
||||
|
||||
result: set[date] = set()
|
||||
for row in session.execute(stmt).all():
|
||||
day_val = row[0]
|
||||
if isinstance(day_val, date):
|
||||
result.add(day_val)
|
||||
else:
|
||||
result.add(date.fromisoformat(str(day_val)))
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -361,7 +470,7 @@ def evaluate_route(
|
||||
session, expected, since, observer_ids, reversible
|
||||
)
|
||||
|
||||
eff_degraded = effective_degraded_threshold(route)
|
||||
eff_clear = effective_clear_threshold(route)
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
for hops in paths.values():
|
||||
@@ -369,7 +478,7 @@ def evaluate_route(
|
||||
ph = hops[0]["packet_hash"]
|
||||
if ph:
|
||||
matched_packets.add(ph)
|
||||
if len(matched_packets) >= eff_degraded:
|
||||
if len(matched_packets) >= eff_clear:
|
||||
return (
|
||||
RouteState.HEALTHY.value,
|
||||
RouteQuality.CLEAR.value,
|
||||
@@ -385,10 +494,205 @@ def evaluate_route(
|
||||
exists = _has_any_hops_in_window(session, since, observer_ids)
|
||||
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
|
||||
|
||||
quality = derive_quality(state, matched_count, threshold, eff_degraded)
|
||||
quality = derive_quality(state, matched_count, threshold, eff_clear)
|
||||
return state, quality, matched_count
|
||||
|
||||
|
||||
def evaluate_route_day(
|
||||
session: Session,
|
||||
route: Route,
|
||||
day_start: datetime,
|
||||
day_end: datetime,
|
||||
) -> tuple[str, str, int]:
|
||||
"""Evaluate a single route for a bounded day window ``[day_start, day_end)``.
|
||||
|
||||
Mirrors :func:`evaluate_route` exactly but bounds the candidate fetch with
|
||||
an upper ``received_at < day_end``. Returns ``(quality, state,
|
||||
matched_count)``.
|
||||
"""
|
||||
expected = _route_expected_hashes(route)
|
||||
if len(expected) < 2:
|
||||
return RouteQuality.UNKNOWN.value, RouteState.NO_COVERAGE.value, 0
|
||||
|
||||
observer_ids = (
|
||||
[ro.node_id for ro in route.route_observers] if route.route_observers else None
|
||||
)
|
||||
|
||||
reversible = getattr(route, "reversible", True)
|
||||
paths = _fetch_candidate_paths_maybe_bidirectional(
|
||||
session, expected, day_start, observer_ids, reversible, until=day_end
|
||||
)
|
||||
|
||||
eff_clear = effective_clear_threshold(route)
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
for hops in paths.values():
|
||||
if _match_hops(hops, expected, route.max_hop_span, reversible):
|
||||
ph = hops[0]["packet_hash"]
|
||||
if ph:
|
||||
matched_packets.add(ph)
|
||||
if len(matched_packets) >= eff_clear:
|
||||
return (
|
||||
RouteQuality.CLEAR.value,
|
||||
RouteState.HEALTHY.value,
|
||||
len(matched_packets),
|
||||
)
|
||||
|
||||
matched_count = len(matched_packets)
|
||||
threshold = route.packet_count_threshold
|
||||
|
||||
if matched_count >= threshold:
|
||||
state = RouteState.HEALTHY.value
|
||||
else:
|
||||
exists = _has_any_hops_in_window(
|
||||
session, day_start, observer_ids, until=day_end
|
||||
)
|
||||
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
|
||||
|
||||
quality = derive_quality(state, matched_count, threshold, eff_clear)
|
||||
return quality, state, matched_count
|
||||
|
||||
|
||||
def evaluate_route_history(
|
||||
session: Session,
|
||||
route: Route,
|
||||
days: int,
|
||||
*,
|
||||
include_today: bool = False,
|
||||
now: Optional[datetime] = None,
|
||||
) -> list[tuple[date, str, str, int]]:
|
||||
"""Evaluate a route over *days* calendar-day buckets (oldest first).
|
||||
|
||||
Returns a list of ``(date, quality, state, matched_count)`` tuples.
|
||||
When *include_today* is True, an extra partial-day entry for today is
|
||||
appended. For a disabled route, every day returns ``unknown`` /
|
||||
``no_coverage`` / ``0`` without hitting the DB.
|
||||
|
||||
Performance: fetches only hops matching the route's expected prefixes
|
||||
(one DB query) plus one GROUP BY existence check, then partitions by
|
||||
day in Python.
|
||||
"""
|
||||
current = now or datetime.now(timezone.utc)
|
||||
today_midnight = current.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
total_days = days + (1 if include_today else 0)
|
||||
oldest = today_midnight - timedelta(days=days)
|
||||
day_dates = [(oldest + timedelta(days=i)).date() for i in range(total_days)]
|
||||
|
||||
if not route.enabled:
|
||||
return [
|
||||
(
|
||||
day_dates[i],
|
||||
RouteQuality.UNKNOWN.value,
|
||||
RouteState.NO_COVERAGE.value,
|
||||
0,
|
||||
)
|
||||
for i in range(total_days)
|
||||
]
|
||||
|
||||
expected = _route_expected_hashes(route)
|
||||
if len(expected) < 2:
|
||||
return [
|
||||
(
|
||||
day_dates[i],
|
||||
RouteQuality.UNKNOWN.value,
|
||||
RouteState.NO_COVERAGE.value,
|
||||
0,
|
||||
)
|
||||
for i in range(total_days)
|
||||
]
|
||||
|
||||
# Compute day boundaries
|
||||
day_starts: list[datetime] = []
|
||||
day_ends: list[datetime] = []
|
||||
for i in range(total_days):
|
||||
if include_today and i == total_days - 1:
|
||||
day_starts.append(today_midnight)
|
||||
day_ends.append(current)
|
||||
else:
|
||||
ds = oldest + timedelta(days=i)
|
||||
day_starts.append(ds)
|
||||
day_ends.append(ds + timedelta(days=1))
|
||||
|
||||
window_start = day_starts[0]
|
||||
window_end = day_ends[-1]
|
||||
|
||||
observer_ids = (
|
||||
[ro.node_id for ro in route.route_observers] if route.route_observers else None
|
||||
)
|
||||
reversible = getattr(route, "reversible", True)
|
||||
|
||||
# --- Filtered fetch: only hops matching the route's prefixes ---
|
||||
# _subsequence_indices only inspects hops whose node_hash starts with
|
||||
# an expected prefix, so filtering at SQL level is semantically
|
||||
# identical but returns far fewer rows than loading all hops for
|
||||
# matching raw packets.
|
||||
all_paths = _fetch_matching_hops(
|
||||
session, expected, window_start, window_end, observer_ids
|
||||
)
|
||||
|
||||
# --- Per-day existence (UNHEALTHY vs NO_COVERAGE) ---
|
||||
day_has_hops = _has_any_hops_per_day(session, day_starts, day_ends, observer_ids)
|
||||
|
||||
# --- Partition packets by day in Python ---
|
||||
# A packet qualifies for a day if it has at least one hop whose
|
||||
# node_hash matches the route's first or last prefix AND received_at
|
||||
# falls within that day's [start, end) range.
|
||||
first_prefix = expected[0]
|
||||
first_prefix_end = _hex_prefix_end(first_prefix)
|
||||
last_prefix: Optional[str] = None
|
||||
last_prefix_end: Optional[str] = None
|
||||
if reversible and len(expected) > 1 and expected[-1] != expected[0]:
|
||||
last_prefix = expected[-1]
|
||||
last_prefix_end = _hex_prefix_end(last_prefix)
|
||||
|
||||
day_paths: list[dict[str, list[dict[str, Any]]]] = [{} for _ in range(total_days)]
|
||||
for rp_id, hops in all_paths.items():
|
||||
for hop in hops:
|
||||
h = hop["node_hash"]
|
||||
ra = hop["received_at"]
|
||||
if ra.tzinfo is None:
|
||||
ra = ra.replace(tzinfo=timezone.utc)
|
||||
if not (
|
||||
first_prefix <= h < first_prefix_end
|
||||
or (last_prefix is not None and last_prefix <= h < last_prefix_end)
|
||||
):
|
||||
continue
|
||||
for i in range(total_days):
|
||||
if day_starts[i] <= ra < day_ends[i]:
|
||||
if rp_id not in day_paths[i]:
|
||||
day_paths[i][rp_id] = hops
|
||||
break
|
||||
|
||||
# --- Evaluate each day (CPU-only, no DB) ---
|
||||
eff_clear = effective_clear_threshold(route)
|
||||
threshold = route.packet_count_threshold
|
||||
|
||||
results: list[tuple[date, str, str, int]] = []
|
||||
for i in range(total_days):
|
||||
matched_packets: set[str] = set()
|
||||
for hops in day_paths[i].values():
|
||||
if _match_hops(hops, expected, route.max_hop_span, reversible):
|
||||
ph = hops[0]["packet_hash"]
|
||||
if ph:
|
||||
matched_packets.add(ph)
|
||||
if len(matched_packets) >= eff_clear:
|
||||
break
|
||||
|
||||
matched_count = len(matched_packets)
|
||||
if matched_count >= threshold:
|
||||
state = RouteState.HEALTHY.value
|
||||
elif day_has_hops and day_dates[i] in day_has_hops:
|
||||
state = RouteState.UNHEALTHY.value
|
||||
else:
|
||||
state = RouteState.NO_COVERAGE.value
|
||||
|
||||
quality = derive_quality(state, matched_count, threshold, eff_clear)
|
||||
results.append((day_dates[i], quality, state, matched_count))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def evaluate_all_routes(
|
||||
session: Session, now: datetime
|
||||
) -> dict[str, tuple[str, str, int]]:
|
||||
@@ -421,7 +725,7 @@ def upsert_route_result(
|
||||
) -> RouteResult:
|
||||
"""Upsert a route evaluation result (ORM check-then-update/insert)."""
|
||||
now = datetime.now(timezone.utc)
|
||||
eff_degraded = effective_degraded_threshold(route)
|
||||
eff_clear = effective_clear_threshold(route)
|
||||
|
||||
existing = session.execute(
|
||||
select(RouteResult).where(RouteResult.route_id == route.id)
|
||||
@@ -432,7 +736,7 @@ def upsert_route_result(
|
||||
existing.quality = quality
|
||||
existing.matched_count = matched_count
|
||||
existing.threshold = route.packet_count_threshold
|
||||
existing.effective_degraded = eff_degraded
|
||||
existing.effective_clear = eff_clear
|
||||
existing.evaluated_at = now
|
||||
return existing
|
||||
|
||||
@@ -443,7 +747,7 @@ def upsert_route_result(
|
||||
quality=quality,
|
||||
matched_count=matched_count,
|
||||
threshold=route.packet_count_threshold,
|
||||
effective_degraded=eff_degraded,
|
||||
effective_clear=eff_clear,
|
||||
evaluated_at=now,
|
||||
)
|
||||
session.add(result)
|
||||
@@ -507,7 +811,7 @@ def preview_route(
|
||||
"""Preview matching for an unsaved route config.
|
||||
|
||||
*config* keys: ``node_ids``, ``match_width``, ``observer_ids``,
|
||||
``max_hop_span``, ``packet_count_threshold``, ``degraded_threshold``,
|
||||
``max_hop_span``, ``packet_count_threshold``, ``clear_threshold``,
|
||||
``reversible``.
|
||||
"""
|
||||
node_ids: list[str] = config.get("node_ids") or []
|
||||
@@ -515,7 +819,7 @@ def preview_route(
|
||||
observer_ids: Optional[list[str]] = config.get("observer_ids") or None
|
||||
max_hop_span: Optional[int] = config.get("max_hop_span")
|
||||
threshold: int = config.get("packet_count_threshold") or 3
|
||||
degraded: Optional[int] = config.get("degraded_threshold")
|
||||
clear_bar: Optional[int] = config.get("clear_threshold")
|
||||
reversible: bool = config.get("reversible", True)
|
||||
|
||||
if len(node_ids) < 2:
|
||||
@@ -567,7 +871,7 @@ def preview_route(
|
||||
session, expected, since, observer_ids, reversible
|
||||
)
|
||||
|
||||
eff_degraded = degraded or (threshold * DEGRADED_DEFAULT_MULTIPLIER)
|
||||
eff_clear = clear_bar or (threshold * CLEAR_DEFAULT_MULTIPLIER)
|
||||
|
||||
matched_packets: set[str] = set()
|
||||
contributing: dict[str, int] = {}
|
||||
@@ -589,7 +893,7 @@ def preview_route(
|
||||
exists = _has_any_hops_in_window(session, since, observer_ids)
|
||||
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
|
||||
|
||||
quality = derive_quality(state, matched_count, threshold, eff_degraded)
|
||||
quality = derive_quality(state, matched_count, threshold, eff_clear)
|
||||
|
||||
collisions_map = prefix_collision_counts(session, match_width)
|
||||
node_collisions = {
|
||||
|
||||
@@ -69,6 +69,10 @@ class PacketPathHop(Base, UUIDMixin, TimestampMixin):
|
||||
"raw_packet_id",
|
||||
"position",
|
||||
),
|
||||
Index(
|
||||
"ix_packet_path_hops_received_at",
|
||||
"received_at",
|
||||
),
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
|
||||
@@ -39,7 +39,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
|
||||
match_width: Path-hash prefix width in bytes (1/2/3)
|
||||
window_hours: Evaluation lookback window in hours
|
||||
packet_count_threshold: Minimum distinct packets for healthy
|
||||
degraded_threshold: Comfort bar for the clear/marginal split (null = 2x threshold)
|
||||
clear_threshold: Comfort bar for the clear/marginal split (null = 2x threshold)
|
||||
max_hop_span: Max hops between first and last configured node (null = unlimited)
|
||||
enabled: Whether this route is actively evaluated
|
||||
"""
|
||||
@@ -75,7 +75,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
|
||||
default=3,
|
||||
nullable=False,
|
||||
)
|
||||
degraded_threshold: Mapped[Optional[int]] = mapped_column(
|
||||
clear_threshold: Mapped[Optional[int]] = mapped_column(
|
||||
Integer,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
@@ -34,7 +34,7 @@ class RouteResult(Base, UUIDMixin, TimestampMixin):
|
||||
"""The latest cached evaluation result for a route (one row per route).
|
||||
|
||||
Written by the background evaluator and read by the API/UI. ``threshold``
|
||||
and ``effective_degraded`` are snapshotted at evaluation time so the display
|
||||
and ``effective_clear`` are snapshotted at evaluation time so the display
|
||||
stays self-consistent if thresholds are later changed.
|
||||
|
||||
Attributes:
|
||||
@@ -44,7 +44,7 @@ class RouteResult(Base, UUIDMixin, TimestampMixin):
|
||||
quality: Display axis (clear / marginal / failing / unknown)
|
||||
matched_count: Distinct packet count (lower bound when short-circuited)
|
||||
threshold: Snapshot of route.packet_count_threshold at eval time
|
||||
effective_degraded: Snapshot of effective_degraded_threshold at eval time
|
||||
effective_clear: Snapshot of effective_clear_threshold at eval time
|
||||
evaluated_at: When this evaluation ran
|
||||
"""
|
||||
|
||||
@@ -72,7 +72,7 @@ class RouteResult(Base, UUIDMixin, TimestampMixin):
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
effective_degraded: Mapped[int] = mapped_column(
|
||||
effective_clear: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -61,7 +61,18 @@ const ChartColors = {
|
||||
'oklch(0.7 0.19 80)', // yellow-green
|
||||
'oklch(0.65 0.22 25)', // orange
|
||||
'oklch(0.55 0 0)' // neutral grey (for "other")
|
||||
]
|
||||
],
|
||||
|
||||
// Semantic quality palette for route health charts. Hardcoded oklch
|
||||
// values (same approach as `breakdown`) — app.css defines no semantic
|
||||
// status colors.
|
||||
quality: {
|
||||
clear: 'oklch(0.72 0.17 145)',
|
||||
marginal: 'oklch(0.75 0.18 85)',
|
||||
failing: 'oklch(0.62 0.24 25)',
|
||||
no_coverage: 'oklch(0.65 0.15 250)',
|
||||
disabled: 'oklch(0.55 0 0)'
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -383,3 +394,66 @@ function initDashboardCharts(nodeData, advertData, messageData, packetData, even
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a per-route health status strip — single horizontal bar of N equal
|
||||
* colored day-segments.
|
||||
*
|
||||
* @param {string} canvasId - ID of the canvas element
|
||||
* @param {Object} routeData - RouteHistory payload with `data` array
|
||||
* @returns {Chart|null}
|
||||
*/
|
||||
function createRouteDetailStrip(canvasId, routeData) {
|
||||
var ctx = document.getElementById(canvasId);
|
||||
if (!ctx || !routeData || !routeData.data || routeData.data.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var existing = Chart.getChart(ctx);
|
||||
if (existing) existing.destroy();
|
||||
|
||||
var datasets = routeData.data.map(function(day) {
|
||||
return {
|
||||
label: day.date,
|
||||
data: [1],
|
||||
backgroundColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
|
||||
borderColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
|
||||
borderWidth: 1,
|
||||
_quality: day.quality,
|
||||
_matched_count: day.matched_count || 0
|
||||
};
|
||||
});
|
||||
|
||||
return new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: { labels: [''], datasets: datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
indexAxis: 'y',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
backgroundColor: ChartColors.tooltipBg,
|
||||
titleColor: ChartColors.tooltipText,
|
||||
bodyColor: ChartColors.tooltipText,
|
||||
borderColor: ChartColors.tooltipBorder,
|
||||
borderWidth: 1,
|
||||
callbacks: {
|
||||
title: function(ctx) { return ctx[0].dataset.label; },
|
||||
label: function(ctx) {
|
||||
var q = ctx.dataset._quality || 'unknown';
|
||||
var label = (window.t && window.t('routes.quality_' + q)) || q;
|
||||
return label + ' (' + ctx.dataset._matched_count + ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: { stacked: true, grid: { display: false }, ticks: { display: false } },
|
||||
y: { stacked: true, grid: { display: false }, ticks: { display: false } }
|
||||
},
|
||||
interaction: { mode: 'nearest', intersect: true }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,6 +174,10 @@ export function iconRuler(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4Z" /><path d="m7.5 10.5 2 2" /><path d="m10.5 7.5 2 2" /><path d="m13.5 4.5 2 2" /><path d="m4.5 13.5 2 2" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconClock(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
|
||||
}
|
||||
|
||||
export function iconFilter(cls = 'h-5 w-5') {
|
||||
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" /></svg>`;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
|
||||
import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
|
||||
import { iconPath, iconPlus, iconEdit, iconTrash, iconChevronRight } from '../icons.js';
|
||||
import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish } from '../icons.js';
|
||||
|
||||
const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin'];
|
||||
|
||||
@@ -79,21 +79,43 @@ function renderPathChips(route) {
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderNumbersLine(route) {
|
||||
function renderStatsRow(route) {
|
||||
const result = route.route_result;
|
||||
if (!result) return html`<div class="text-xs opacity-50 mt-1">${t('routes.not_evaluated')}</div>`;
|
||||
const matched = result.matched_count ?? '?';
|
||||
const threshold = result.threshold ?? '?';
|
||||
const degraded = result.effective_degraded ?? '?';
|
||||
const evalTime = result.evaluated_at
|
||||
? new Date(result.evaluated_at).toLocaleTimeString()
|
||||
: '?';
|
||||
return html`<div class="text-xs opacity-60 mt-1">
|
||||
${matched} / ${threshold} \u2192 ${degraded} \u00B7 ${route.window_hours}h \u00B7 ${evalTime}
|
||||
const matched = result?.matched_count ?? '?';
|
||||
const threshold = result?.threshold ?? '?';
|
||||
const degraded = result?.effective_clear ?? '?';
|
||||
const nodeCount = (route.route_nodes || []).length;
|
||||
const obsCount = (route.route_observers || []).length;
|
||||
|
||||
return html`<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs opacity-60 mt-1">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
${iconPackets('h-3.5 w-3.5')}
|
||||
<span>${matched}/${threshold}\u2192${degraded}</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
${iconClock('h-3.5 w-3.5')}
|
||||
<span>${route.window_hours}h</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
${iconRuler('h-3.5 w-3.5')}
|
||||
<span>${route.match_width}B</span>
|
||||
</span>
|
||||
<span class="inline-flex items-center gap-1">
|
||||
${iconNodes('h-3.5 w-3.5')}
|
||||
<span>${nodeCount}</span>
|
||||
</span>
|
||||
${route.max_hop_span ? html`<span class="inline-flex items-center gap-1">
|
||||
${iconPath('h-3.5 w-3.5')}
|
||||
<span>${route.max_hop_span}</span>
|
||||
</span>` : nothing}
|
||||
<span class="inline-flex items-center gap-1">
|
||||
${iconSatelliteDish('h-3.5 w-3.5')}
|
||||
<span>${obsCount || '\u221E'}</span>
|
||||
</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpanded, detail, navigate, packetsEnabled }) {
|
||||
function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, packetsEnabled, history }) {
|
||||
const q = route.route_result?.quality || 'unknown';
|
||||
const badgeCls = qualityBadgeClass(q, route.enabled);
|
||||
const label = qualityLabel(q, route.enabled);
|
||||
@@ -105,22 +127,24 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande
|
||||
: html`<span class="badge ${badgeCls} badge-sm">${dot} ${label}</span>`;
|
||||
|
||||
const adminButtons = isAdmin
|
||||
? html`<div class="flex gap-2 mt-2">
|
||||
<button class="btn btn-xs btn-outline" @click=${(e) => { e.stopPropagation(); onEdit(route); }}>
|
||||
? html`<div class="flex gap-2 mt-auto pt-2">
|
||||
<button class="btn btn-xs btn-outline" @click=${() => onEdit(route)}>
|
||||
${iconEdit('h-3 w-3')} ${t('common.edit')}
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline btn-error" @click=${(e) => { e.stopPropagation(); onDelete(route); }}>
|
||||
<button class="btn btn-xs btn-outline btn-error" @click=${() => onDelete(route)}>
|
||||
${iconTrash('h-3 w-3')} ${t('common.delete')}
|
||||
</button>
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
const expandContent = isExpanded && detail ? renderDetailContent(route, detail, { navigate, packetsEnabled }) : nothing;
|
||||
const expandContent = detail
|
||||
? renderDetailContent(route, detail, { navigate, packetsEnabled, history })
|
||||
: html`<div class="mt-4 pt-4 border-t border-base-300 flex justify-center">
|
||||
<span class="loading loading-spinner loading-sm opacity-50"></span>
|
||||
</div>`;
|
||||
|
||||
return html`<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body cursor-pointer" role="button" tabindex="0"
|
||||
@click=${() => onExpand(route)}
|
||||
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onExpand(route); } }}>
|
||||
return html`<div class="card bg-base-100 shadow-xl h-full">
|
||||
<div class="card-body">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h2 class="card-title flex items-center gap-2 flex-wrap">
|
||||
@@ -132,34 +156,36 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
${badge}
|
||||
${iconChevronRight(`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2">${renderPathChips(route)}</div>
|
||||
${renderNumbersLine(route)}
|
||||
<div class="text-xs opacity-50 mt-0.5">
|
||||
${route.match_width}B \u00B7 ${(route.route_nodes || []).length} ${t('routes.nodes_count')}${route.max_hop_span ? html` \u00B7 ${t('routes.span')}: ${route.max_hop_span}` : nothing}
|
||||
</div>
|
||||
${adminButtons}
|
||||
${renderStatsRow(route)}
|
||||
${expandContent}
|
||||
${adminButtons}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDetailContent(route, detail, { navigate, packetsEnabled }) {
|
||||
const result = detail.route_result || route.route_result;
|
||||
const observers = detail.contributing_observers || [];
|
||||
function renderDetailContent(route, detail, { navigate, packetsEnabled, history }) {
|
||||
const matches = detail.recent_matches || [];
|
||||
const packetDetailUrl = (packetHash) =>
|
||||
(packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null;
|
||||
|
||||
return html`<div class="mt-4 pt-4 border-t border-base-300 space-y-3 text-sm">
|
||||
${observers.length > 0 ? html`<div>
|
||||
<strong class="opacity-70">${t('routes.contributing_observers')}:</strong>
|
||||
${observers.map(o => html`<span class="badge badge-ghost badge-sm ml-1">${o.name || o.node_id.slice(0, 8)} (${o.match_count})</span>`)}
|
||||
</div>` : html`<div class="opacity-50">${t('routes.no_observers')}</div>`}
|
||||
const historySection = history
|
||||
? html`<div class="mb-3">
|
||||
<div style="height: 40px;">
|
||||
<canvas id="routeStripChart-${route.id}"></canvas>
|
||||
</div>
|
||||
${history.data && history.data.length > 0 ? html`<div class="flex text-xs opacity-50 mt-0.5">
|
||||
${history.data.map(d => html`<span class="flex-1 text-center">${new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}</span>`)}
|
||||
</div>` : nothing}
|
||||
</div>`
|
||||
: nothing;
|
||||
|
||||
return html`<div class="mt-2 space-y-3 text-sm">
|
||||
${historySection}
|
||||
${matches.length > 0 ? html`<div>
|
||||
<strong class="opacity-70">${t('routes.recent_matches')}:</strong>
|
||||
<strong class="opacity-70">${t('routes.recent_packets')}</strong>
|
||||
<div class="mt-1 space-y-2">
|
||||
${matches.map(m => {
|
||||
const prefixLen = 2 * (route.match_width || 1);
|
||||
@@ -184,12 +210,6 @@ function renderDetailContent(route, detail, { navigate, packetsEnabled }) {
|
||||
})}
|
||||
</div>
|
||||
</div>` : nothing}
|
||||
<div class="opacity-50 text-xs">
|
||||
${t('routes.width')}: ${route.match_width} \u00B7
|
||||
${t('routes.window')}: ${route.window_hours}h \u00B7
|
||||
${t('routes.threshold')}: ${route.packet_count_threshold} \u00B7
|
||||
${route.max_hop_span ? html`${t('routes.span')}: ${route.max_hop_span}` : nothing}
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
@@ -353,10 +373,10 @@ function renderRouteModal({ modalState, onSave, onCancel }) {
|
||||
.value=${route.packet_count_threshold || 3} min="1" max="10000" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.degraded_label')}</label>
|
||||
<input type="number" id="route-modal-degraded" class="input input-sm w-full"
|
||||
.value=${route.degraded_threshold || ''}
|
||||
placeholder="2x" min="1" />
|
||||
<label class="text-sm opacity-70">${t('routes.clear_label')}</label>
|
||||
<input type="number" id="route-modal-clear" class="input input-sm w-full"
|
||||
.value=${route.clear_threshold || ''}
|
||||
placeholder="${2 * (route.packet_count_threshold || 3)}" min="1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-sm opacity-70">${t('routes.span_label')}</label>
|
||||
@@ -416,29 +436,44 @@ export async function render(container, params, router) {
|
||||
const routes = data.items || [];
|
||||
|
||||
let modalState = null;
|
||||
let expandedId = null;
|
||||
const detailCache = new Map();
|
||||
const historyCache = new Map();
|
||||
const chartRegistry = [];
|
||||
|
||||
function destroyCharts() {
|
||||
chartRegistry.forEach(c => { try { c.destroy(); } catch (_) {} });
|
||||
chartRegistry.length = 0;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const newData = await apiGet('/api/v1/routes');
|
||||
renderPage(newData.items || []);
|
||||
routes.splice(0, routes.length, ...(newData.items || []));
|
||||
renderPage(routes);
|
||||
loadAllDetails(routes);
|
||||
}
|
||||
|
||||
async function handleExpand(route) {
|
||||
if (expandedId === route.id) {
|
||||
expandedId = null;
|
||||
} else {
|
||||
expandedId = route.id;
|
||||
if (!detailCache.has(route.id)) {
|
||||
try {
|
||||
const detail = await apiGet(`/api/v1/routes/${route.id}`);
|
||||
detailCache.set(route.id, detail);
|
||||
} catch (e) {
|
||||
// ignore — card still shows basic info
|
||||
}
|
||||
async function loadAllDetails(routesList) {
|
||||
const promises = [];
|
||||
for (const r of routesList) {
|
||||
if (!detailCache.has(r.id)) {
|
||||
promises.push(
|
||||
apiGet(`/api/v1/routes/${r.id}`, {}, { signal })
|
||||
.then(d => detailCache.set(r.id, d))
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
if (!historyCache.has(r.id)) {
|
||||
promises.push(
|
||||
apiGet(`/api/v1/routes/${r.id}/history`, { days: 6 }, { signal })
|
||||
.then(h => historyCache.set(r.id, h))
|
||||
.catch(() => {})
|
||||
);
|
||||
}
|
||||
}
|
||||
renderPage(routes);
|
||||
if (promises.length > 0) {
|
||||
await Promise.allSettled(promises);
|
||||
renderPage(routes);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(routesList) {
|
||||
@@ -468,11 +503,10 @@ export async function render(container, params, router) {
|
||||
isAdmin,
|
||||
onDelete: handleDeleteClick,
|
||||
onEdit: handleEditClick,
|
||||
onExpand: handleExpand,
|
||||
isExpanded: (r) => expandedId === r.id,
|
||||
detail: (r) => detailCache.get(r.id),
|
||||
navigate,
|
||||
packetsEnabled,
|
||||
history: (r) => historyCache.get(r.id),
|
||||
};
|
||||
|
||||
const groupedSections = [];
|
||||
@@ -487,8 +521,8 @@ export async function render(container, params, router) {
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
${group.map(r => renderRouteCard(r, {
|
||||
...cardOpts,
|
||||
isExpanded: cardOpts.isExpanded(r),
|
||||
detail: cardOpts.detail(r),
|
||||
history: cardOpts.history(r),
|
||||
}))}
|
||||
</div>
|
||||
`);
|
||||
@@ -509,6 +543,8 @@ export async function render(container, params, router) {
|
||||
});
|
||||
}
|
||||
|
||||
destroyCharts();
|
||||
|
||||
litRender(html`
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold flex items-center gap-2">
|
||||
@@ -522,6 +558,13 @@ export async function render(container, params, router) {
|
||||
${groupedSections}
|
||||
${modalHtml}
|
||||
`, container);
|
||||
|
||||
for (const r of routesList) {
|
||||
if (historyCache.has(r.id)) {
|
||||
const chart = window.createRouteDetailStrip(`routeStripChart-${r.id}`, historyCache.get(r.id));
|
||||
if (chart) chartRegistry.push(chart);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function _newModalState(type, route) {
|
||||
@@ -702,7 +745,7 @@ export async function render(container, params, router) {
|
||||
const widthEl = document.getElementById('route-modal-width');
|
||||
const windowEl = document.getElementById('route-modal-window');
|
||||
const thresholdEl = document.getElementById('route-modal-threshold');
|
||||
const degradedEl = document.getElementById('route-modal-degraded');
|
||||
const clearEl = document.getElementById('route-modal-clear');
|
||||
const spanEl = document.getElementById('route-modal-span');
|
||||
const enabledEl = document.getElementById('route-modal-enabled');
|
||||
const reversibleEl = document.getElementById('route-modal-reversible');
|
||||
@@ -731,14 +774,16 @@ export async function render(container, params, router) {
|
||||
observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,
|
||||
};
|
||||
|
||||
const degradedVal = degradedEl.value.trim();
|
||||
if (degradedVal) {
|
||||
body.degraded_threshold = parseInt(degradedVal, 10);
|
||||
const clearVal = clearEl.value.trim();
|
||||
if (clearVal) {
|
||||
body.clear_threshold = parseInt(clearVal, 10);
|
||||
}
|
||||
|
||||
try {
|
||||
if (isEdit) {
|
||||
await apiPut(`/api/v1/routes/${modalState.route.id}`, body);
|
||||
detailCache.delete(modalState.route.id);
|
||||
historyCache.delete(modalState.route.id);
|
||||
} else {
|
||||
await apiPost('/api/v1/routes', body);
|
||||
}
|
||||
@@ -760,6 +805,11 @@ export async function render(container, params, router) {
|
||||
}
|
||||
|
||||
renderPage(routes);
|
||||
loadAllDetails(routes);
|
||||
|
||||
return () => {
|
||||
destroyCharts();
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
if (isAbortError(e)) return;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user