diff --git a/SCHEMAS.md b/SCHEMAS.md index 90a5478..7ec89fe 100644 --- a/SCHEMAS.md +++ b/SCHEMAS.md @@ -189,6 +189,11 @@ Group/broadcast messages on specific channels. - Payload type `4` location metadata (`appData.location.latitude/longitude`) is mapped to node `lat/lon` for map rendering. - This keeps advertisement persistence aligned with meshcore-packet-capture expectations (advertisement traffic only). +**Compatibility ingest note (envelope fields)**: +- The LetsMesh upload envelope carries `SNR` and `path` fields alongside the decoded packet payload. These are available on all packet types (messages, advertisements, traces, telemetry). +- The normalizer extracts `SNR` (normalized to lowercase `snr`) and `path` (converted to `path_len` via hop count) from the envelope and includes them in the normalized payload. +- Per-observer `snr` and `path_len` are stored in the `event_observers` junction table, allowing each observer to record its own signal strength and hop count. + **Compatibility ingest note (non-message structured events)**: - Decoded payload type `9` is normalized to `TRACE_DATA` (`traceTag`, flags, auth, path hashes, and SNR values). - Decoded payload type `11` (`Control/NodeDiscoverResp`) is normalized to `contact` events for node upsert parity. @@ -514,6 +519,22 @@ See [AGENTS.md](AGENTS.md) for webhook configuration details. --- +## API Response: Observer Info + +Events that support multi-observer tracking (messages, advertisements, trace paths, telemetry) include an `observers` array in API responses. Each observer entry contains: + +| Field | Type | Description | +|-------|------|-------------| +| `node_id` | string (UUID) | Observer node UUID | +| `public_key` | string (64 hex chars) | Observer node public key | +| `name` | string or null | Observer node advertised name | +| `tag_name` | string or null | Observer name from node tags | +| `snr` | number or null | Signal-to-noise ratio at this observer (dB) | +| `path_len` | integer or null | Hop count at this observer | +| `observed_at` | string (ISO 8601) | When this observer captured the event | + +--- + ## Event Flow 1. **Hardware/Mock MeshCore** → Generates raw events diff --git a/alembic/versions/20260426_1052_a10dbca883a2_add_path_len_to_event_observers.py b/alembic/versions/20260426_1052_a10dbca883a2_add_path_len_to_event_observers.py new file mode 100644 index 0000000..72b8d66 --- /dev/null +++ b/alembic/versions/20260426_1052_a10dbca883a2_add_path_len_to_event_observers.py @@ -0,0 +1,45 @@ +"""add path_len to event_observers + +Revision ID: a10dbca883a2 +Revises: b1c2d3e4f5a6 +Create Date: 2026-04-26 10:52:09.664958+00:00 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision: str = "a10dbca883a2" +down_revision: Union[str, None] = "b1c2d3e4f5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("event_observers", schema=None) as batch_op: + batch_op.add_column(sa.Column("path_len", sa.Integer(), nullable=True)) + batch_op.drop_constraint( + batch_op.f("uq_event_receivers_hash_node"), type_="unique" + ) + batch_op.create_unique_constraint( + "uq_event_observers_hash_node", ["event_hash", "observer_node_id"] + ) + + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table("event_observers", schema=None) as batch_op: + batch_op.drop_constraint("uq_event_observers_hash_node", type_="unique") + batch_op.create_unique_constraint( + batch_op.f("uq_event_receivers_hash_node"), + ["event_hash", "observer_node_id"], + ) + batch_op.drop_column("path_len") + + # ### end Alembic commands ### diff --git a/docs/plans/20260426-1137-improve-snr-path-visibility/plan.md b/docs/plans/20260426-1137-improve-snr-path-visibility/plan.md new file mode 100644 index 0000000..59a67ac --- /dev/null +++ b/docs/plans/20260426-1137-improve-snr-path-visibility/plan.md @@ -0,0 +1,342 @@ +# Observer Detail Rows — Implementation Plan + +**Date:** 2026-04-26 +**Status:** Approved + +## Decisions + +1. **No backfill** — Historical data unchanged. Per-observer `path_len` only captured for new events. +2. **Canonical case: lowercase `snr`** — All code normalizes SNR references to lowercase. +3. **Trace observer pattern** — Per-observer `path_len` tracks the observer's hop count to the event source. + +## Terminology + +The original research used outdated names. This plan uses the correct codebase terminology: + +| Old Reference | Actual Codebase | +|---------------|-----------------| +| `event_receivers` | `event_observers` | +| `add_event_receiver()` | `add_event_observer()` | +| `_fetch_receivers_for_events()` | `_fetch_observers_for_events()` | +| `ReceiverInfo` | `ObserverInfo` | +| `receivers` (API field) | `observers` | + +## Verified Current State + +### Database (`event_observers` table) + +``` +event_observers +├── id UUID PK +├── event_type String(20) +├── event_hash String(32) +├── observer_node_id FK → nodes.id +├── snr Float (nullable) +├── observed_at DateTime +├── created_at DateTime +└── updated_at DateTime +``` + +**Missing:** `path_len` column. + +### Per-Event vs Per-Observer Fields + +| Field | Scope | Rationale | +|-------|-------|-----------| +| `snr` | **Per-observer** | Signal strength differs by observer location | +| `path_len` | **Per-observer** | Hop count differs by observer position in mesh topology | +| `observed_at` | **Per-observer** | Each observer sees the event at a different time | +| `snr_values` (trace) | **Per-event only** | Per-hop SNR along the trace path | +| `hop_count` (trace) | **Per-event only** | Total hops in the trace | + +### Handler Payload Extraction + +| Handler | SNR extraction | path_len extraction | +|---------|---------------|-------------------| +| `message.py` | `payload.get("SNR") or payload.get("snr")` | `payload.get("path_len")` | +| `advertisement.py` | None | None | +| `trace.py` | None | `payload.get("path_len")` | +| `telemetry.py` | None | None | + +Each handler has exactly 3 `add_event_observer()` call sites: +1. Duplicate path (existing event) +2. First observer (new event) +3. Race condition recovery + +All call sites need `path_len` parameter. + +### LetsMesh Normalizer + +`_build_letsmesh_advertisement_payload()` (handles decoded packet type 4) does NOT extract envelope `SNR` or `path`. The message payload method already does (lines 143-160). **Note:** The message method outputs `normalized_payload["SNR"]` (uppercase) at line 160, which contradicts Decision #2. This must be changed to lowercase `"snr"` alongside the advertisement fix. + +### API Routes + +| Route | Populates `observers`? | Notes | +|-------|----------------------|-------| +| `api/routes/messages.py` | Yes | Uses `_fetch_observers_for_events()` | +| `api/routes/advertisements.py` | Yes | Uses `_fetch_observers_for_events()` | +| `api/routes/trace_paths.py` | No — returns `[]` | Schema has field but never queries | +| `api/routes/telemetry.py` | No — returns `[]` | Same issue | + +`_fetch_observers_for_events()` is duplicated identically in `messages.py` and `advertisements.py`. + +### Frontend + +- **`components.js:444` `receiverIcons()`** — Dead code. Uses wrong property names (`receiver_node_name` / `receiver_node_public_key`). No page imports it. +- **`messages.js` and `advertisements.js`** — Render satellite dish icons with correct property names (`recv.tag_name`, `recv.name`, `recv.public_key`) but display name-only tooltips. Per-observer SNR/path_len is returned by API but never displayed. +- **No trace_paths.js or telemetry.js frontend pages exist.** +- **No expandable/collapsible row patterns** exist anywhere in the SPA. + +### Schema (`ObserverInfo`) + +```python +class ObserverInfo(BaseModel): + node_id: str + public_key: str + name: Optional[str] + tag_name: Optional[str] + snr: Optional[float] + observed_at: datetime +``` + +**Missing:** `path_len` field. + +--- + +## Implementation Plan + +### Phase 1: Database Schema — Add `path_len` to `event_observers` + +**File:** `src/meshcore_hub/common/models/event_observer.py` + +- Add column: `path_len: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)` +- Update `add_event_observer()` signature to accept `path_len: Optional[int] = None` +- Include `path_len` in `sqlite_insert().values()` call + +**Migration:** +```bash +source .venv/bin/activate +meshcore-hub db revision --autogenerate -m "add path_len to event_observers" +meshcore-hub db upgrade +``` + +### Phase 2: API Utility & Schema + +**New file:** `src/meshcore_hub/api/observer_utils.py` + +Move `_fetch_observers_for_events()` into a shared module. Update the query to also select `EventObserver.path_len`. + +```python +def _fetch_observers_for_events( + session: DbSession, + event_type: str, + event_hashes: list[str], +) -> dict[str, list[ObserverInfo]]: +``` + +The query selects: `EventObserver.event_hash`, `EventObserver.snr`, `EventObserver.path_len`, `EventObserver.observed_at`, `Node.id`, `Node.public_key`, `Node.name`. Also fetches `NodeTag` name tags. + +**Schema update:** `src/meshcore_hub/common/schemas/messages.py` + +Add to `ObserverInfo`: +```python +path_len: Optional[int] = Field(default=None, description="Hop count at this observer") +``` + +**Route updates:** + +| File | Change | +|------|--------| +| `api/routes/messages.py` | Remove local `_fetch_observers_for_events`, import from `observer_utils` | +| `api/routes/advertisements.py` | Same | +| `api/routes/trace_paths.py` | Import and call `_fetch_observers_for_events(session, "trace", event_hashes)` to populate `observers` | +| `api/routes/telemetry.py` | Import and call `_fetch_observers_for_events(session, "telemetry", event_hashes)` to populate `observers` | + +### Phase 3: LetsMesh Normalizer — Extract Envelope SNR & Path for Advertisements + +**File:** `src/meshcore_hub/collector/letsmesh_normalizer.py` + +In `_build_letsmesh_advertisement_payload()`, add after the `normalized_payload` dict initialization (line 574): + +```python +snr = self._parse_float(payload.get("SNR")) +if snr is None: + snr = self._parse_float(payload.get("snr")) +if snr is not None: + normalized_payload["snr"] = snr + +path_len = self._parse_path_length(payload.get("path")) +if path_len is not None: + normalized_payload["path_len"] = path_len +``` + +This follows the same pattern used in `_build_letsmesh_message_payload()` (lines 143-160), with the key difference being lowercase `"snr"` output. + +> **Inaccuracy corrected (review 2026-04-26):** The message normalizer at line 160 outputs `normalized_payload["SNR"]` (uppercase). Phase 4 below removes the handler's uppercase fallback (`payload.get("SNR") or ...`). Without also fixing the normalizer output, LetsMesh-routed messages would lose SNR data. The change below is added to enforce Decision #2 (canonical lowercase `snr`) consistently. + +In `_build_letsmesh_message_payload()`, change line 160: +```python +# Before: +normalized_payload["SNR"] = snr +# After: +normalized_payload["snr"] = snr +``` + +### Phase 4: Collector Handlers — Pass SNR & path_len + +**File:** `src/meshcore_hub/collector/handlers/message.py` + +- Change line 78: `payload.get("SNR") or payload.get("snr")` → `payload.get("snr")` +- `path_len` is already extracted (line 75) — just pass it to all 3 `add_event_observer()` call sites (lines 124, 158, 178) + +**File:** `src/meshcore_hub/collector/handlers/advertisement.py` + +- Add extraction: `snr = payload.get("snr")` +- Add extraction: `path_len = payload.get("path_len")` +- Pass both to all 3 `add_event_observer()` call sites (lines 120, 182, 203) + +**File:** `src/meshcore_hub/collector/handlers/trace.py` + +- Add extraction: `snr = payload.get("snr")` +- `path_len` is already extracted (line 38) — pass both to all 3 `add_event_observer()` call sites (lines 74, 106, 126) + +**File:** `src/meshcore_hub/collector/handlers/telemetry.py` + +- Add extraction: `snr = payload.get("snr")` +- Add extraction: `path_len = payload.get("path_len")` +- Pass both to all 3 `add_event_observer()` call sites (lines 87, 133, 154) + +### Phase 5: Frontend Components + +**File:** `src/meshcore_hub/web/static/js/spa/components.js` + +- **Remove** dead `receiverIcons()` function (lines 444-452) — uses wrong property names, no page imports it +- **Add** `observerDetailRow(observers, eventProperties)` component: + - Renders an expandable sub-table below the event row + - Observer columns: + - **Observer** — `tag_name || name || truncateKey(public_key, 12)`, linked to `/nodes/${public_key}` + - **SNR** — Formatted as "X.X dB" or "—" if null + - **Path** — Formatted as "N hops" or "—" if null + - **Received** — Relative time via `formatRelativeTime(observed_at)` + - `eventProperties` parameter for event-level context (e.g., trace `snr_values`) + - Toggle helper: click event row to show/hide `.observer-detail` row below it +- **Add** `observerIcons(observers)` — count badge with tooltip listing observer names + +**File:** `src/meshcore_hub/web/static/css/app.css` + +- `.observer-detail` expandable row styles (indented, compact sub-table) +- CSS transition for smooth expand/collapse (max-height animation) +- Responsive: desktop table vs mobile card layout + +### Phase 6: Frontend Pages + +**File:** `src/meshcore_hub/web/static/js/spa/pages/messages.js` + +Replace current satellite dish icon rendering with: +- Observer count badge in the Receivers column (clickable to expand) +- Expandable detail row showing per-observer: name, SNR, path_len, observed_at +- Both desktop table (~line 255) and mobile card (~line 206) views + +**File:** `src/meshcore_hub/web/static/js/spa/pages/advertisements.js` + +Same pattern as messages. +- Desktop table (~line 135) +- Mobile card (~line 99) + +**Note:** No `trace_paths.js` frontend page exists. No frontend changes needed for trace paths — API changes in Phase 2 will surface observer data for future pages or API consumers. + +### Phase 7: Tests + +**`tests/test_collector/test_letsmesh_normalizer.py`** +- Test type 4 packet extracts `snr` (lowercase) and `path_len` from envelope +- Test both `"SNR"` and `"snr"` input casing normalizes to lowercase `"snr"` output +- Test message payload also outputs lowercase `"snr"` (verifies line 160 casing fix) + +**`tests/test_collector/test_handlers/test_advertisement.py`** +- Test handler with `snr` and `path_len` in payload → stored in `event_observers` + +**`tests/test_collector/test_handlers/test_message.py`** +- Test handler passes `path_len` to `add_event_observer()` +- Fix casing: change `"SNR": 15.5` → `"snr": 15.5` (line 21) and `"SNR": 8.5` → `"snr": 8.5` (line 102) + +**`tests/test_collector/test_handlers/test_trace.py`** +- Test handler with envelope `snr`/`path_len` → stored in `event_observers` + +**`tests/test_collector/test_handlers/test_telemetry.py`** +- Test handler with envelope `snr`/`path_len` → stored in `event_observers` + +**`tests/test_common/test_models.py`** +- Test `add_event_observer()` accepts and stores `path_len` +- Test backwards compatibility (`path_len=None` is default) + +**`tests/test_api/test_trace_paths.py`** +- Test `observers` list is populated (query returns data) + +**`tests/test_api/test_telemetry.py`** +- Test `observers` list is populated (query returns data) + +**Run commands:** +```bash +source .venv/bin/activate +pytest tests/test_collector/ -v +pytest tests/test_api/ -v +pytest tests/test_common/ -v +pre-commit run --all-files +``` + +### Phase 8: Documentation + +**`SCHEMAS.md`:** +- Document that `SNR` and `path` are LetsMesh envelope fields available on all packet types +- Update `ObserverInfo` description to include `path_len` + +**`AGENTS.md`:** +- Update `event_observers` table description to include `path_len` column + +--- + +## File Change Summary + +| # | File | Action | Phase | +|---|------|--------|-------| +| 1 | `common/models/event_observer.py` | Modify | 1 | +| 2 | `alembic/versions/*.py` | Create | 1 | +| 3 | `common/schemas/messages.py` | Modify | 2 | +| 4 | `api/observer_utils.py` | Create | 2 | +| 5 | `api/routes/messages.py` | Modify | 2 | +| 6 | `api/routes/advertisements.py` | Modify | 2 | +| 7 | `api/routes/trace_paths.py` | Modify | 2 | +| 8 | `api/routes/telemetry.py` | Modify | 2 | +| 9 | `collector/letsmesh_normalizer.py` | Modify | 3 | +| 10 | `collector/handlers/message.py` | Modify | 4 | +| 11 | `collector/handlers/advertisement.py` | Modify | 4 | +| 12 | `collector/handlers/trace.py` | Modify | 4 | +| 13 | `collector/handlers/telemetry.py` | Modify | 4 | +| 14 | `web/static/js/spa/components.js` | Modify | 5 | +| 15 | `web/static/css/app.css` | Modify | 5 | +| 16 | `web/static/js/spa/pages/messages.js` | Modify | 6 | +| 17 | `web/static/js/spa/pages/advertisements.js` | Modify | 6 | +| 18 | `SCHEMAS.md` | Modify | 8 | +| 19 | `AGENTS.md` | Modify | 8 | + +--- + +## Source Files Reference + +| File | Key Locations | +|------|--------------| +| `common/models/event_observer.py` | `EventObserver` model, `add_event_observer()` helper | +| `common/hash_utils.py` | Hash computation for deduplication | +| `common/schemas/messages.py` | `ObserverInfo`, `MessageRead`, `AdvertisementRead`, `TracePathRead`, `TelemetryRead` | +| `collector/letsmesh_normalizer.py` | `_build_letsmesh_advertisement_payload()` (line 544), `_build_letsmesh_message_payload()` (line 84) | +| `collector/handlers/advertisement.py` | 3x `add_event_observer()` at lines 120, 182, 203 | +| `collector/handlers/message.py` | 3x `add_event_observer()` at lines 124, 158, 178 | +| `collector/handlers/trace.py` | 3x `add_event_observer()` at lines 74, 106, 126 | +| `collector/handlers/telemetry.py` | 3x `add_event_observer()` at lines 87, 133, 154 | +| `api/routes/messages.py` | `_fetch_observers_for_events()` at line 28 | +| `api/routes/advertisements.py` | `_fetch_observers_for_events()` at line 42 | +| `api/routes/trace_paths.py` | Returns `[]` for observers | +| `api/routes/telemetry.py` | Returns `[]` for observers | +| `web/static/js/spa/components.js` | Dead `receiverIcons()` at line 444 | +| `web/static/js/spa/pages/messages.js` | Observer rendering at lines 206-216 (mobile), 255-267 (desktop) | +| `web/static/js/spa/pages/advertisements.js` | Observer rendering at lines 99-109 (mobile), 135-147 (desktop) | diff --git a/docs/plans/20260426-1137-improve-snr-path-visibility/tasks.md b/docs/plans/20260426-1137-improve-snr-path-visibility/tasks.md new file mode 100644 index 0000000..20ecad4 --- /dev/null +++ b/docs/plans/20260426-1137-improve-snr-path-visibility/tasks.md @@ -0,0 +1,127 @@ +# Observer Detail Rows — Task Checklist + +**Plan:** `docs/plans/20260426-1137-improve-snr-path-visibility/plan.md` +**Status:** Complete + +## Review Notes + +> **Plan accuracy issue found:** Phase 4 changes `message.py` handler to read only `payload.get("snr")` (lowercase), but the LetsMesh **message** normalizer (`letsmesh_normalizer.py:160`) still outputs `normalized_payload["SNR"]` (uppercase). Phase 3 only fixes the **advertisement** normalizer. An additional task (Phase 3.5) is included below to normalize the message normalizer output to lowercase `"snr"`, consistent with Decision #2. + +--- + +## Phase 1: Database Schema + +- [x] **1.1** Add `path_len: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)` to `EventObserver` model in `src/meshcore_hub/common/models/event_observer.py` +- [x] **1.2** Add `path_len: Optional[int] = None` parameter to `add_event_observer()` signature +- [x] **1.3** Include `path_len=path_len` in `sqlite_insert().values()` call +- [x] **1.4** Generate Alembic migration: `meshcore-hub db revision --autogenerate -m "add path_len to event_observers"` +- [x] **1.5** Review and adjust migration file +- [x] **1.6** Run migration: `meshcore-hub db upgrade` + +## Phase 2: API Utility & Schema + +- [x] **2.1** Add `path_len: Optional[int] = Field(default=None, description="Hop count at this observer")` to `ObserverInfo` schema in `src/meshcore_hub/common/schemas/messages.py` +- [x] **2.2** Create `src/meshcore_hub/api/observer_utils.py` — move shared `_fetch_observers_for_events()` function +- [x] **2.3** Update shared query to also select `EventObserver.path_len` +- [x] **2.4** Include `path_len=row.path_len` in `ObserverInfo()` construction within the shared function +- [x] **2.5** Update `api/routes/messages.py` — remove local `_fetch_observers_for_events`, import from `observer_utils`; also remove local `_get_tag_name` if it becomes unused +- [x] **2.6** Update `api/routes/advertisements.py` — same as 2.5 +- [x] **2.7** Update `api/routes/trace_paths.py` — import and call `_fetch_observers_for_events(session, "trace", event_hashes)` for both list and detail endpoints; include `"observers"` key in response dicts +- [x] **2.8** Update `api/routes/telemetry.py` — same pattern as 2.7 with `"telemetry"` event type + +## Phase 3: LetsMesh Normalizer — Advertisement SNR & Path + +- [x] **3.1** In `src/meshcore_hub/collector/letsmesh_normalizer.py`, in `_build_letsmesh_advertisement_payload()`, add SNR extraction after `normalized_payload` dict initialization (~line 574): `snr = self._parse_float(payload.get("SNR"))`, fallback to `payload.get("snr")`, store as `normalized_payload["snr"]` (lowercase) +- [x] **3.2** Add `path_len = self._parse_path_length(payload.get("path"))` extraction, store as `normalized_payload["path_len"]` +- [x] **3.3** Return the updated `normalized_payload` (already returned at line 627) + +## Phase 3.5: LetsMesh Normalizer — Message SNR Casing Fix + +> **Accuracy fix:** Not in original plan. Required to prevent SNR data loss for LetsMesh-routed messages after Phase 4 removes the uppercase fallback. + +- [x] **3.5.1** In `src/meshcore_hub/collector/letsmesh_normalizer.py`, change line 160: `normalized_payload["SNR"]` → `normalized_payload["snr"]` (lowercase, per Decision #2) + +## Phase 4: Collector Handlers + +- [x] **4.1** `src/meshcore_hub/collector/handlers/message.py` — change line 78: `payload.get("SNR") or payload.get("snr")` → `payload.get("snr")` +- [x] **4.2** `message.py` — pass `path_len=path_len` to all 3 `add_event_observer()` call sites (lines 124, 158, 178) +- [x] **4.3** `src/meshcore_hub/collector/handlers/advertisement.py` — add extraction: `snr = payload.get("snr")` and `path_len = payload.get("path_len")` +- [x] **4.4** `advertisement.py` — pass `snr=snr, path_len=path_len` to all 3 `add_event_observer()` call sites (lines 120, 182, 203); replace current `snr=None` +- [x] **4.5** `src/meshcore_hub/collector/handlers/trace.py` — add extraction: `snr = payload.get("snr")` +- [x] **4.6** `trace.py` — pass `snr=snr, path_len=path_len` to all 3 `add_event_observer()` call sites (lines 74, 106, 126); replace current `snr=None` +- [x] **4.7** `src/meshcore_hub/collector/handlers/telemetry.py` — add extraction: `snr = payload.get("snr")` and `path_len = payload.get("path_len")` +- [x] **4.8** `telemetry.py` — pass `snr=snr, path_len=path_len` to all 3 `add_event_observer()` call sites (lines 87, 133, 154); replace current `snr=None` + +## Phase 5: Frontend Components + +- [x] **5.1** Remove dead `receiverIcons()` function from `src/meshcore_hub/web/static/js/spa/components.js` (lines 444-452) +- [x] **5.2** Add `observerDetailRow(observers, eventProperties)` component to `components.js`: + - Renders expandable sub-table with columns: Observer (name/link), SNR (formatted dB), Path (formatted hops), Received (relative time) + - `eventProperties` param for event-level context (e.g., trace `snr_values`) + - Toggle helper: click event row to show/hide `.observer-detail` row +- [x] **5.3** Add `observerIcons(observers)` component — count badge with tooltip listing observer names +- [x] **5.4** Add `.observer-detail` expandable row styles to `src/meshcore_hub/web/static/css/app.css`: + - Indented, compact sub-table styling + - CSS transition for smooth expand/collapse (max-height animation) + - Responsive: desktop table vs mobile card layout + +## Phase 6: Frontend Pages + +- [x] **6.1** Update `src/meshcore_hub/web/static/js/spa/pages/messages.js`: + - Replace satellite dish icon rendering with observer count badge (clickable to expand) + - Add expandable detail row showing per-observer: name, SNR, path_len, observed_at + - Update both mobile card view (~line 206) and desktop table view (~line 255) +- [x] **6.2** Update `src/meshcore_hub/web/static/js/spa/pages/advertisements.js`: + - Same pattern as messages + - Update both mobile card view (~line 99) and desktop table view (~line 135) + +## Phase 7: Tests + +- [x] **7.1** `tests/test_collector/test_letsmesh_normalizer.py` — test type 4 packet extracts `snr` (lowercase) and `path_len` from envelope +- [x] **7.2** `tests/test_collector/test_letsmesh_normalizer.py` — test both `"SNR"` and `"snr"` input casing normalizes to lowercase `"snr"` output +- [x] **7.3** `tests/test_collector/test_letsmesh_normalizer.py` — test message payload also outputs lowercase `"snr"` (verifies Phase 3.5 fix) +- [x] **7.4** `tests/test_collector/test_handlers/test_advertisement.py` — test handler with `snr` and `path_len` in payload → stored in `event_observers` +- [x] **7.5** `tests/test_collector/test_handlers/test_message.py` — test handler passes `path_len` to `add_event_observer()` +- [x] **7.6** `tests/test_collector/test_handlers/test_message.py` — fix casing: `"SNR": 15.5` → `"snr": 15.5` (line 21) and `"SNR": 8.5` → `"snr": 8.5` (line 102) +- [x] **7.7** `tests/test_collector/test_handlers/test_trace.py` — test handler with envelope `snr`/`path_len` → stored in `event_observers` +- [x] **7.8** `tests/test_collector/test_handlers/test_telemetry.py` — test handler with envelope `snr`/`path_len` → stored in `event_observers` +- [x] **7.9** `tests/test_common/test_models.py` — test `add_event_observer()` accepts and stores `path_len` +- [x] **7.10** `tests/test_common/test_models.py` — test backwards compatibility (`path_len=None` is default) +- [x] **7.11** `tests/test_api/test_trace_paths.py` — test `observers` list is populated (query returns data) +- [x] **7.12** `tests/test_api/test_telemetry.py` — test `observers` list is populated (query returns data) +- [x] **7.13** Run targeted tests: `pytest tests/test_collector/ -v` +- [x] **7.14** Run targeted tests: `pytest tests/test_api/ -v` +- [x] **7.15** Run targeted tests: `pytest tests/test_common/ -v` +- [x] **7.16** Run quality checks: `pre-commit run --all-files` + +## Phase 8: Documentation + +- [x] **8.1** Update `SCHEMAS.md` — document that `SNR` and `path` are LetsMesh envelope fields available on all packet types +- [x] **8.2** Update `SCHEMAS.md` — update `ObserverInfo` description to include `path_len` +- [x] **8.3** Update `AGENTS.md` — update `event_observers` table description to include `path_len` column + +--- + +## File Change Summary + +| # | File | Action | Phase(s) | +|---|------|--------|----------| +| 1 | `common/models/event_observer.py` | Modify | 1 | +| 2 | `alembic/versions/*.py` | Create | 1 | +| 3 | `common/schemas/messages.py` | Modify | 2 | +| 4 | `api/observer_utils.py` | Create | 2 | +| 5 | `api/routes/messages.py` | Modify | 2 | +| 6 | `api/routes/advertisements.py` | Modify | 2 | +| 7 | `api/routes/trace_paths.py` | Modify | 2 | +| 8 | `api/routes/telemetry.py` | Modify | 2 | +| 9 | `collector/letsmesh_normalizer.py` | Modify | 3, 3.5 | +| 10 | `collector/handlers/message.py` | Modify | 4 | +| 11 | `collector/handlers/advertisement.py` | Modify | 4 | +| 12 | `collector/handlers/trace.py` | Modify | 4 | +| 13 | `collector/handlers/telemetry.py` | Modify | 4 | +| 14 | `web/static/js/spa/components.js` | Modify | 5 | +| 15 | `web/static/css/app.css` | Modify | 5 | +| 16 | `web/static/js/spa/pages/messages.js` | Modify | 6 | +| 17 | `web/static/js/spa/pages/advertisements.js` | Modify | 6 | +| 18 | `SCHEMAS.md` | Modify | 8 | +| 19 | `AGENTS.md` | Modify | 8 | diff --git a/src/meshcore_hub/api/observer_utils.py b/src/meshcore_hub/api/observer_utils.py new file mode 100644 index 0000000..21a4a79 --- /dev/null +++ b/src/meshcore_hub/api/observer_utils.py @@ -0,0 +1,75 @@ +"""Shared utilities for fetching event observer data.""" + +from sqlalchemy import select + +from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.common.models import EventObserver, Node, NodeTag +from meshcore_hub.common.schemas.messages import ObserverInfo + + +def fetch_observers_for_events( + session: DbSession, + event_type: str, + event_hashes: list[str], +) -> dict[str, list[ObserverInfo]]: + """Fetch observer info for a list of events by their hashes. + + Args: + session: Database session + event_type: Type of event ('message', 'advertisement', etc.) + event_hashes: List of event hashes to fetch observers for + + Returns: + Dict mapping event_hash to list of ObserverInfo objects + """ + if not event_hashes: + return {} + + query = ( + select( + EventObserver.event_hash, + EventObserver.snr, + EventObserver.path_len, + EventObserver.observed_at, + Node.id.label("node_id"), + Node.public_key, + Node.name, + ) + .join(Node, EventObserver.observer_node_id == Node.id) + .where(EventObserver.event_type == event_type) + .where(EventObserver.event_hash.in_(event_hashes)) + .order_by(EventObserver.observed_at) + ) + + results = session.execute(query).all() + + observers_by_hash: dict[str, list[ObserverInfo]] = {} + + node_ids = [r.node_id for r in results] + tag_names: dict[str, str] = {} + if node_ids: + tag_query = ( + select(NodeTag.node_id, NodeTag.value) + .where(NodeTag.node_id.in_(node_ids)) + .where(NodeTag.key == "name") + ) + for node_id, value in session.execute(tag_query).all(): + tag_names[node_id] = value + + for row in results: + if row.event_hash not in observers_by_hash: + observers_by_hash[row.event_hash] = [] + + observers_by_hash[row.event_hash].append( + ObserverInfo( + node_id=row.node_id, + public_key=row.public_key, + name=row.name, + tag_name=tag_names.get(row.node_id), + snr=row.snr, + path_len=row.path_len, + observed_at=row.observed_at, + ) + ) + + return observers_by_hash diff --git a/src/meshcore_hub/api/routes/advertisements.py b/src/meshcore_hub/api/routes/advertisements.py index a3d7b87..68d7548 100644 --- a/src/meshcore_hub/api/routes/advertisements.py +++ b/src/meshcore_hub/api/routes/advertisements.py @@ -9,11 +9,11 @@ from sqlalchemy.orm import aliased, selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import Advertisement, EventObserver, Node, NodeTag +from meshcore_hub.api.observer_utils import fetch_observers_for_events +from meshcore_hub.common.models import Advertisement, Node, NodeTag from meshcore_hub.common.schemas.messages import ( AdvertisementList, AdvertisementRead, - ObserverInfo, ) router = APIRouter() @@ -39,62 +39,6 @@ def _get_tag_description(node: Optional[Node]) -> Optional[str]: return None -def _fetch_observers_for_events( - session: DbSession, - event_type: str, - event_hashes: list[str], -) -> dict[str, list[ObserverInfo]]: - """Fetch receiver info for a list of events by their hashes.""" - if not event_hashes: - return {} - - query = ( - select( - EventObserver.event_hash, - EventObserver.snr, - EventObserver.observed_at, - Node.id.label("node_id"), - Node.public_key, - Node.name, - ) - .join(Node, EventObserver.observer_node_id == Node.id) - .where(EventObserver.event_type == event_type) - .where(EventObserver.event_hash.in_(event_hashes)) - .order_by(EventObserver.observed_at) - ) - - results = session.execute(query).all() - observers_by_hash: dict[str, list[ObserverInfo]] = {} - - node_ids = [r.node_id for r in results] - tag_names: dict[str, str] = {} - if node_ids: - tag_query = ( - select(NodeTag.node_id, NodeTag.value) - .where(NodeTag.node_id.in_(node_ids)) - .where(NodeTag.key == "name") - ) - for node_id, value in session.execute(tag_query).all(): - tag_names[node_id] = value - - for row in results: - if row.event_hash not in observers_by_hash: - observers_by_hash[row.event_hash] = [] - - observers_by_hash[row.event_hash].append( - ObserverInfo( - node_id=row.node_id, - public_key=row.public_key, - name=row.name, - tag_name=tag_names.get(row.node_id), - snr=row.snr, - observed_at=row.observed_at, - ) - ) - - return observers_by_hash - - @router.get("", response_model=AdvertisementList) async def list_advertisements( _: RequireRead, @@ -201,7 +145,7 @@ async def list_advertisements( # Fetch all observers for these advertisements event_hashes = [r[0].event_hash for r in results if r[0].event_hash] - observers_by_hash = _fetch_observers_for_events( + observers_by_hash = fetch_observers_for_events( session, "advertisement", event_hashes ) @@ -290,7 +234,7 @@ async def get_advertisement( # Fetch observers for this advertisement observers = [] if adv.event_hash: - observers_by_hash = _fetch_observers_for_events( + observers_by_hash = fetch_observers_for_events( session, "advertisement", [adv.event_hash] ) observers = observers_by_hash.get(adv.event_hash, []) diff --git a/src/meshcore_hub/api/routes/messages.py b/src/meshcore_hub/api/routes/messages.py index 29dea68..701cd8c 100644 --- a/src/meshcore_hub/api/routes/messages.py +++ b/src/meshcore_hub/api/routes/messages.py @@ -9,8 +9,9 @@ from sqlalchemy.orm import aliased, selectinload from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession -from meshcore_hub.common.models import EventObserver, Message, Node, NodeTag -from meshcore_hub.common.schemas.messages import MessageList, MessageRead, ObserverInfo +from meshcore_hub.api.observer_utils import fetch_observers_for_events +from meshcore_hub.common.models import Message, Node, NodeTag +from meshcore_hub.common.schemas.messages import MessageList, MessageRead router = APIRouter() @@ -25,75 +26,6 @@ def _get_tag_name(node: Optional[Node]) -> Optional[str]: return None -def _fetch_observers_for_events( - session: DbSession, - event_type: str, - event_hashes: list[str], -) -> dict[str, list[ObserverInfo]]: - """Fetch receiver info for a list of events by their hashes. - - Args: - session: Database session - event_type: Type of event ('message', 'advertisement', etc.) - event_hashes: List of event hashes to fetch observers for - - Returns: - Dict mapping event_hash to list of ObserverInfo objects - """ - if not event_hashes: - return {} - - # Query event_observers with receiver node info - query = ( - select( - EventObserver.event_hash, - EventObserver.snr, - EventObserver.observed_at, - Node.id.label("node_id"), - Node.public_key, - Node.name, - ) - .join(Node, EventObserver.observer_node_id == Node.id) - .where(EventObserver.event_type == event_type) - .where(EventObserver.event_hash.in_(event_hashes)) - .order_by(EventObserver.observed_at) - ) - - results = session.execute(query).all() - - # Group by event_hash - observers_by_hash: dict[str, list[ObserverInfo]] = {} - - # Get tag names for receiver nodes - node_ids = [r.node_id for r in results] - tag_names: dict[str, str] = {} - if node_ids: - tag_query = ( - select(NodeTag.node_id, NodeTag.value) - .where(NodeTag.node_id.in_(node_ids)) - .where(NodeTag.key == "name") - ) - for node_id, value in session.execute(tag_query).all(): - tag_names[node_id] = value - - for row in results: - if row.event_hash not in observers_by_hash: - observers_by_hash[row.event_hash] = [] - - observers_by_hash[row.event_hash].append( - ObserverInfo( - node_id=row.node_id, - public_key=row.public_key, - name=row.name, - tag_name=tag_names.get(row.node_id), - snr=row.snr, - observed_at=row.observed_at, - ) - ) - - return observers_by_hash - - @router.get("", response_model=MessageList) async def list_messages( _: RequireRead, @@ -197,7 +129,7 @@ async def list_messages( # Fetch all observers for these messages event_hashes = [r[0].event_hash for r in results if r[0].event_hash] - observers_by_hash = _fetch_observers_for_events(session, "message", event_hashes) + observers_by_hash = fetch_observers_for_events(session, "message", event_hashes) # Build response with sender info and observed_by items = [] @@ -269,7 +201,7 @@ async def get_message( # Fetch observers for this message observers = [] if message.event_hash: - observers_by_hash = _fetch_observers_for_events( + observers_by_hash = fetch_observers_for_events( session, "message", [message.event_hash] ) observers = observers_by_hash.get(message.event_hash, []) diff --git a/src/meshcore_hub/api/routes/telemetry.py b/src/meshcore_hub/api/routes/telemetry.py index e56743f..002678e 100644 --- a/src/meshcore_hub/api/routes/telemetry.py +++ b/src/meshcore_hub/api/routes/telemetry.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.api.observer_utils import fetch_observers_for_events from meshcore_hub.common.models import Node, Telemetry from meshcore_hub.common.schemas.messages import TelemetryList, TelemetryRead @@ -59,6 +60,10 @@ async def list_telemetry( # Execute results = session.execute(query).all() + # Fetch observers for these telemetry records + event_hashes = [tel.event_hash for tel, _ in results if tel.event_hash] + observers_by_hash = fetch_observers_for_events(session, "telemetry", event_hashes) + # Build response with observed_by items = [] for tel, observer_pk in results: @@ -71,6 +76,9 @@ async def list_telemetry( "parsed_data": tel.parsed_data, "received_at": tel.received_at, "created_at": tel.created_at, + "observers": ( + observers_by_hash.get(tel.event_hash, []) if tel.event_hash else [] + ), } items.append(TelemetryRead(**data)) @@ -101,6 +109,14 @@ async def get_telemetry( raise HTTPException(status_code=404, detail="Telemetry record not found") tel, observer_pk = result + + observers = [] + if tel.event_hash: + observers_by_hash = fetch_observers_for_events( + session, "telemetry", [tel.event_hash] + ) + observers = observers_by_hash.get(tel.event_hash, []) + data = { "id": tel.id, "observer_node_id": tel.observer_node_id, @@ -110,5 +126,6 @@ async def get_telemetry( "parsed_data": tel.parsed_data, "received_at": tel.received_at, "created_at": tel.created_at, + "observers": observers, } return TelemetryRead(**data) diff --git a/src/meshcore_hub/api/routes/trace_paths.py b/src/meshcore_hub/api/routes/trace_paths.py index e9c576d..b592157 100644 --- a/src/meshcore_hub/api/routes/trace_paths.py +++ b/src/meshcore_hub/api/routes/trace_paths.py @@ -9,6 +9,7 @@ from sqlalchemy.orm import aliased from meshcore_hub.api.auth import RequireRead from meshcore_hub.api.dependencies import DbSession +from meshcore_hub.api.observer_utils import fetch_observers_for_events from meshcore_hub.common.models import Node, TracePath from meshcore_hub.common.schemas.messages import TracePathList, TracePathRead @@ -55,6 +56,10 @@ async def list_trace_paths( # Execute results = session.execute(query).all() + # Fetch observers for these trace paths + event_hashes = [tp.event_hash for tp, _ in results if tp.event_hash] + observers_by_hash = fetch_observers_for_events(session, "trace", event_hashes) + # Build response with observed_by items = [] for tp, observer_pk in results: @@ -71,6 +76,9 @@ async def list_trace_paths( "hop_count": tp.hop_count, "received_at": tp.received_at, "created_at": tp.created_at, + "observers": ( + observers_by_hash.get(tp.event_hash, []) if tp.event_hash else [] + ), } items.append(TracePathRead(**data)) @@ -101,6 +109,14 @@ async def get_trace_path( raise HTTPException(status_code=404, detail="Trace path not found") tp, observer_pk = result + + observers = [] + if tp.event_hash: + observers_by_hash = fetch_observers_for_events( + session, "trace", [tp.event_hash] + ) + observers = observers_by_hash.get(tp.event_hash, []) + data = { "id": tp.id, "observer_node_id": tp.observer_node_id, @@ -114,5 +130,6 @@ async def get_trace_path( "hop_count": tp.hop_count, "received_at": tp.received_at, "created_at": tp.created_at, + "observers": observers, } return TracePathRead(**data) diff --git a/src/meshcore_hub/collector/handlers/advertisement.py b/src/meshcore_hub/collector/handlers/advertisement.py index fb3d1e1..e92b07b 100644 --- a/src/meshcore_hub/collector/handlers/advertisement.py +++ b/src/meshcore_hub/collector/handlers/advertisement.py @@ -72,6 +72,9 @@ def handle_advertisement( lon = _coerce_float(lon) now = datetime.now(timezone.utc) + snr = payload.get("snr") + path_len = payload.get("path_len") + # Compute event hash for deduplication (30-second time bucket) event_hash = compute_advertisement_hash( public_key=adv_public_key, @@ -122,7 +125,8 @@ def handle_advertisement( event_type="advertisement", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, # Advertisements don't have SNR + snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -184,7 +188,8 @@ def handle_advertisement( event_type="advertisement", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) @@ -205,7 +210,8 @@ def handle_advertisement( event_type="advertisement", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/handlers/message.py b/src/meshcore_hub/collector/handlers/message.py index 8797145..8cb3ae5 100644 --- a/src/meshcore_hub/collector/handlers/message.py +++ b/src/meshcore_hub/collector/handlers/message.py @@ -75,7 +75,7 @@ def _handle_message( path_len = payload.get("path_len") txt_type = payload.get("txt_type") signature = payload.get("signature") - snr = payload.get("SNR") or payload.get("snr") + snr = payload.get("snr") # Parse sender timestamp sender_ts = payload.get("sender_timestamp") @@ -127,6 +127,7 @@ def _handle_message( event_hash=event_hash, observer_node_id=receiver_node.id, snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -161,6 +162,7 @@ def _handle_message( event_hash=event_hash, observer_node_id=receiver_node.id, snr=snr, + path_len=path_len, observed_at=now, ) @@ -181,6 +183,7 @@ def _handle_message( event_hash=event_hash, observer_node_id=receiver_node.id, snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/handlers/telemetry.py b/src/meshcore_hub/collector/handlers/telemetry.py index 43b9a95..0883d27 100644 --- a/src/meshcore_hub/collector/handlers/telemetry.py +++ b/src/meshcore_hub/collector/handlers/telemetry.py @@ -37,6 +37,8 @@ def handle_telemetry( lpp_data = payload.get("lpp_data") parsed_data = payload.get("parsed_data") + snr = payload.get("snr") + path_len = payload.get("path_len") # Convert lpp_data to bytes if it's a string or list lpp_bytes = None @@ -89,7 +91,8 @@ def handle_telemetry( event_type="telemetry", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -135,7 +138,8 @@ def handle_telemetry( event_type="telemetry", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) @@ -156,7 +160,8 @@ def handle_telemetry( event_type="telemetry", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/handlers/trace.py b/src/meshcore_hub/collector/handlers/trace.py index 98b233f..2b3c7e4 100644 --- a/src/meshcore_hub/collector/handlers/trace.py +++ b/src/meshcore_hub/collector/handlers/trace.py @@ -41,6 +41,7 @@ def handle_trace_data( path_hashes = payload.get("path_hashes") snr_values = payload.get("snr_values") hop_count = payload.get("hop_count") + snr = payload.get("snr") # Compute event hash for deduplication (initiator_tag is unique per trace) event_hash = compute_trace_hash(initiator_tag=initiator_tag) @@ -76,7 +77,8 @@ def handle_trace_data( event_type="trace", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, # Trace events don't have a single SNR value + snr=snr, + path_len=path_len, observed_at=now, ) if added: @@ -108,7 +110,8 @@ def handle_trace_data( event_type="trace", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) @@ -128,7 +131,8 @@ def handle_trace_data( event_type="trace", event_hash=event_hash, observer_node_id=receiver_node.id, - snr=None, + snr=snr, + path_len=path_len, observed_at=now, ) return diff --git a/src/meshcore_hub/collector/letsmesh_normalizer.py b/src/meshcore_hub/collector/letsmesh_normalizer.py index 416df7f..c292312 100644 --- a/src/meshcore_hub/collector/letsmesh_normalizer.py +++ b/src/meshcore_hub/collector/letsmesh_normalizer.py @@ -157,7 +157,8 @@ class LetsMeshNormalizer: if snr is None: snr = self._parse_float(payload.get("snr")) if snr is not None: - normalized_payload["SNR"] = snr + normalized_payload["snr"] = snr + normalized_payload.pop("SNR", None) decoded_sender = self._extract_letsmesh_decoder_sender( decoded_packet, @@ -575,6 +576,16 @@ class LetsMeshNormalizer: "public_key": public_key, } + snr = self._parse_float(payload.get("SNR")) + if snr is None: + snr = self._parse_float(payload.get("snr")) + if snr is not None: + normalized_payload["snr"] = snr + + path_len = self._parse_path_length(payload.get("path")) + if path_len is not None: + normalized_payload["path_len"] = path_len + app_data = decoded_payload.get("appData") if isinstance(app_data, dict): name = app_data.get("name") diff --git a/src/meshcore_hub/common/models/event_observer.py b/src/meshcore_hub/common/models/event_observer.py index 36fd1c0..14442d7 100644 --- a/src/meshcore_hub/common/models/event_observer.py +++ b/src/meshcore_hub/common/models/event_observer.py @@ -4,7 +4,15 @@ from datetime import datetime from typing import TYPE_CHECKING, Optional from uuid import uuid4 -from sqlalchemy import DateTime, Float, ForeignKey, Index, String, UniqueConstraint +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + Integer, + Index, + String, + UniqueConstraint, +) from sqlalchemy.dialects.sqlite import insert as sqlite_insert from sqlalchemy.orm import Mapped, Session, mapped_column, relationship @@ -27,6 +35,7 @@ class EventObserver(Base, UUIDMixin, TimestampMixin): event_hash: Hash identifying the unique event (links to event tables) observer_node_id: FK to the node that observed this event snr: Signal-to-noise ratio at this observer (if available) + path_len: Hop count at this observer (if available) observed_at: When this specific observer captured the event created_at: Record creation timestamp updated_at: Record update timestamp @@ -53,6 +62,10 @@ class EventObserver(Base, UUIDMixin, TimestampMixin): Float, nullable=True, ) + path_len: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + ) observed_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=utc_now, @@ -85,6 +98,7 @@ def add_event_observer( event_hash: str, observer_node_id: str, snr: Optional[float] = None, + path_len: Optional[int] = None, observed_at: Optional[datetime] = None, ) -> bool: """Add an observer to an event, handling duplicates gracefully. @@ -97,6 +111,7 @@ def add_event_observer( event_hash: Hash identifying the unique event observer_node_id: UUID of the observer node snr: Signal-to-noise ratio at this observer (optional) + path_len: Hop count at this observer (optional) observed_at: When this observer captured the event (defaults to now) Returns: @@ -114,6 +129,7 @@ def add_event_observer( event_hash=event_hash, observer_node_id=observer_node_id, snr=snr, + path_len=path_len, observed_at=now, created_at=now, updated_at=now, diff --git a/src/meshcore_hub/common/schemas/messages.py b/src/meshcore_hub/common/schemas/messages.py index 7e84b38..c551d19 100644 --- a/src/meshcore_hub/common/schemas/messages.py +++ b/src/meshcore_hub/common/schemas/messages.py @@ -16,6 +16,9 @@ class ObserverInfo(BaseModel): snr: Optional[float] = Field( default=None, description="Signal-to-noise ratio at this observer" ) + path_len: Optional[int] = Field( + default=None, description="Hop count at this observer" + ) observed_at: datetime = Field( ..., description="When this observer captured the event" ) diff --git a/src/meshcore_hub/web/static/css/app.css b/src/meshcore_hub/web/static/css/app.css index b65bfd9..5a20813 100644 --- a/src/meshcore_hub/web/static/css/app.css +++ b/src/meshcore_hub/web/static/css/app.css @@ -348,3 +348,41 @@ #header-map .leaflet-control { z-index: auto !important; } + +/* ========================================================================== + Observer Detail Rows + Expandable sub-table showing per-observer signal/path data. + ========================================================================== */ + +.observer-detail .observer-detail-content { + padding: 0.5rem 1rem 0.5rem 2rem; +} + +.observer-detail table { + margin: 0; +} + +.observer-badge { + transition: background-color 0.15s ease; +} + +.observer-badge:hover { + background-color: color-mix(in oklch, var(--color-base-content) 15%, transparent); +} + +.observer-badge-group { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} + +/* Mobile card observer details */ +.observer-detail-card { + padding: 0.5rem 0.75rem; + margin-top: 0.25rem; +} + +.observer-detail-card table { + margin: 0; + font-size: 0.8rem; +} diff --git a/src/meshcore_hub/web/static/js/spa/components.js b/src/meshcore_hub/web/static/js/spa/components.js index 4376c63..aa18d3c 100644 --- a/src/meshcore_hub/web/static/js/spa/components.js +++ b/src/meshcore_hub/web/static/js/spa/components.js @@ -437,18 +437,83 @@ export function timezoneIndicator() { } /** - * Render receiver node icons with tooltips. - * @param {Array} receivers + * Render an observer count badge with tooltip listing observer names. + * @param {Array} observers - Array of observer objects * @returns {TemplateResult|nothing} */ -export function receiverIcons(receivers) { - if (!receivers || receivers.length === 0) return nothing; - return html`${receivers.map(r => { - const name = r.receiver_node_name || truncateKey(r.receiver_node_public_key || '', 8); - const time = formatRelativeTime(r.received_at); - const tooltip = time ? `${name} (${time})` : name; - return html`\u{1F4E1}`; - })}`; +export function observerIcons(observers) { + if (!observers || observers.length === 0) return nothing; + const names = observers.map(o => o.tag_name || o.name || truncateKey(o.public_key, 8)); + const tooltip = names.join(', '); + return html`\u{1F4E1}${observers.length}`; +} + +/** + * Render an expandable observer detail row. + * Shows per-observer: name, SNR, path_len, observed_at. + * @param {Array} observers - Array of observer objects + * @param {Object} [eventProperties] - Event-level context (unused, for future use) + * @returns {TemplateResult|nothing} + */ +export function observerDetailRow(observers, eventProperties, options = {}) { + if (!observers || observers.length === 0) return nothing; + const showPath = !options.hidePath; + return html` +
+ `; +} + +/** + * Toggle observer detail row visibility when clicking an event row. + * @param {Event} event - Click event + */ +export function toggleObserverDetail(event) { + const row = event.currentTarget; + const detailRow = row.nextElementSibling; + if (detailRow && detailRow.classList.contains('observer-detail')) { + detailRow.classList.toggle('hidden'); + } +} + +export function toggleCardObserverDetail(event) { + event.stopPropagation(); + event.preventDefault(); + const card = event.currentTarget.closest('.card'); + if (card) { + const detail = card.querySelector('.observer-detail-card'); + if (detail) detail.classList.toggle('hidden'); + } } // --- Form Helpers --- diff --git a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js index f35cd2f..0c18aee 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/advertisements.js +++ b/src/meshcore_hub/web/static/js/spa/pages/advertisements.js @@ -1,9 +1,10 @@ import { apiGet } from '../api.js'; import { html, litRender, nothing, t, - getConfig, formatDateTime, formatDateTimeShort, + getConfig, formatDateTime, formatDateTimeShort, formatRelativeTime, truncateKey, errorAlert, - pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay + pagination, createFilterHandler, autoSubmit, submitOnEnter, copyToClipboard, renderNodeDisplay, + observerIcons, observerDetailRow, toggleObserverDetail, toggleCardObserverDetail } from '../components.js'; import { createAutoRefresh } from '../auto-refresh.js'; @@ -98,15 +99,9 @@ ${content}`, container); const adDescription = ad.node_tag_description; let receiversBlock = nothing; if (ad.observers && ad.observers.length >= 1) { - receiversBlock = html`${displayMessage}
+ ${msg.observers && msg.observers.length > 0 ? html` + + ` : nothing} `; }); @@ -254,19 +270,13 @@ ${content}`, container); : sender; let receiversBlock; if (msg.observers && msg.observers.length >= 1) { - receiversBlock = html`