diff --git a/README.md b/README.md
index 6bfd4fa7..1a25585f 100644
--- a/README.md
+++ b/README.md
@@ -284,6 +284,7 @@ Start with the doc that matches what you are trying to do.
- **[Configuration Guide](docs/CONFIGURATION.md):** all config options, private channels, relay, upstream, MQTT, radio tuning
- **[Radio Config Explained](docs/RADIO-CONFIG-EXPLAINED.md):** the "why" behind region, spreading factor, bandwidth, custom slots, Part 15 awareness
- **[MQTT and Meshradar](docs/MQTT-AND-MESHRADAR.md):** the two cloud paths side-by-side, what data flows where, privacy posture
+- **[Home Assistant cookbook](docs/HOME-ASSISTANT-COOKBOOK.md):** copy-paste REST sensors, alerts, and broadcast automations for LAN integrations
- **[Network Watchdog](docs/NETWORK-WATCHDOG.md):** how the WiFi auto-recovery service works, default thresholds, re-enabling auto-reboot
**When something goes wrong**
@@ -292,6 +293,7 @@ Start with the doc that matches what you are trying to do.
- **[Troubleshooting](docs/TROUBLESHOOTING.md):** longer diagnostic flows, recovery from corrupted installs
**Project**
+- **[Contributor PR roadmap](docs/plans/master-pr-roadmap.md):** ordered queue for diagnostics, intelligence, and field-ops PRs
- **[Changelog](docs/CHANGELOG.md):** version history and release notes
- **[GitHub Issues](https://github.com/KMX415/meshpoint/issues)** and **[Discussions](https://github.com/KMX415/meshpoint/discussions)** for bugs and questions
- **[Discord](https://discord.gg/BnhSeFXVY8)** for real-time community support
diff --git a/docs/HOME-ASSISTANT-COOKBOOK.md b/docs/HOME-ASSISTANT-COOKBOOK.md
new file mode 100644
index 00000000..43630a93
--- /dev/null
+++ b/docs/HOME-ASSISTANT-COOKBOOK.md
@@ -0,0 +1,125 @@
+# Home Assistant Cookbook
+
+Copy-paste recipes for Meshpoint LAN automation. Enable the API first
+(see [API-AUTOMATION.md](./API-AUTOMATION.md)), then add these snippets
+to `configuration.yaml` or package files.
+
+All examples assume:
+
+- Meshpoint dashboard: `http://192.168.1.50:8080`
+- Token stored in `secrets.yaml` as `meshpoint_automation_token`
+
+## Node count sensor
+
+```yaml
+rest:
+ - resource: "http://192.168.1.50:8080/api/automation/nodes?limit=500"
+ headers:
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ scan_interval: 60
+ sensor:
+ - name: "Mesh Nodes"
+ unique_id: meshpoint_node_count
+ value_template: "{{ value_json | length }}"
+ unit_of_measurement: "nodes"
+ icon: mdi:access-point-network
+```
+
+## Meshpoint health binary
+
+Alerts when the automation API stops responding (power loss, service crash).
+
+```yaml
+rest:
+ - resource: "http://192.168.1.50:8080/api/automation/status"
+ headers:
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ scan_interval: 30
+ binary_sensor:
+ - name: "Meshpoint Online"
+ unique_id: meshpoint_online
+ value_template: "{{ value_json.uptime_seconds is defined }}"
+ device_class: connectivity
+```
+
+## Relay activity sensor
+
+```yaml
+rest:
+ - resource: "http://192.168.1.50:8080/api/automation/status"
+ headers:
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ scan_interval: 60
+ sensor:
+ - name: "Meshpoint Relayed"
+ unique_id: meshpoint_relayed_total
+ value_template: "{{ value_json.relay.relayed | default(0) }}"
+ unit_of_measurement: "packets"
+ - name: "Meshpoint Relay Rejected"
+ unique_id: meshpoint_relay_rejected
+ value_template: "{{ value_json.relay.rejected | default(0) }}"
+ unit_of_measurement: "packets"
+```
+
+## Broadcast from an automation
+
+```yaml
+rest_command:
+ meshpoint_broadcast:
+ url: "http://192.168.1.50:8080/api/automation/send"
+ method: POST
+ headers:
+ Content-Type: "application/json"
+ X-Meshpoint-Token: !secret meshpoint_automation_token
+ payload: '{"text":"{{ message }}","channel":0,"destination":"broadcast"}'
+
+automation:
+ - alias: "Mesh morning check-in"
+ trigger:
+ - platform: time
+ at: "08:00:00"
+ action:
+ - service: rest_command.meshpoint_broadcast
+ data:
+ message: "Good morning from Home Assistant"
+```
+
+## Alert when node count drops
+
+Useful when a backbone node disappears from the mesh.
+
+```yaml
+automation:
+ - alias: "Mesh node count drop"
+ trigger:
+ - platform: numeric_state
+ entity_id: sensor.mesh_nodes
+ below: 5
+ for: "00:05:00"
+ action:
+ - service: notify.mobile_app
+ data:
+ title: "Meshpoint alert"
+ message: "Node count fell below 5 for 5 minutes"
+```
+
+## MQTT + Home Assistant (packet stream)
+
+If `mqtt.enabled` is true, Meshpoint can publish decoded packets to your
+broker. Enable **Home Assistant auto-discovery** under Configuration β
+MQTT, or subscribe manually to `msh//2/json/...` topics.
+
+The Configuration MQTT card shows live broker health: connection state,
+publish count, disconnect count, and topic prefix.
+
+## Node-RED
+
+Use an **http request** node pointed at `/api/automation/status` or
+`/api/automation/nodes` with header `X-Meshpoint-Token`. Poll every 30β60 s
+for dashboards; use inject + POST for one-shot messages.
+
+## Security notes
+
+- Keep `automation.token` at 32+ random characters.
+- Do not expose port 8080 to the internet.
+- MQTT credentials live in `config/local.yaml`; treat that file like a secret.
diff --git a/docs/plans/master-pr-roadmap.md b/docs/plans/master-pr-roadmap.md
new file mode 100644
index 00000000..5e2c2bd0
--- /dev/null
+++ b/docs/plans/master-pr-roadmap.md
@@ -0,0 +1,773 @@
+# Meshpoint β Master PR Roadmap (16 PRs)
+
+**Purpose:** Ordered pull-request queue across three feature-request batches. Submit **one at a time**; wait for merge or explicit feedback before opening the next.
+**Workflow:** [CONTRIBUTING.md](../../CONTRIBUTING.md) β branch from current `upstream/main`, one focused PR per branch, squash-merge on `KMX415/meshpoint:main`.
+**Risk tiers** (from CONTRIBUTING.md):
+
+| Tier | Meaning |
+|------|---------|
+| π’ Green | Frontend or read-only backend β minimal review |
+| π‘ Yellow | New backend logic / config β standard review |
+| π΄ Red | Relay module or concentrator path β extra review |
+
+**Sources:**
+
+| Code | Suite |
+|------|-------|
+| FR1 | Feature Request 1 β Operator Diagnostics |
+| FR2 | Feature Request 2 β Operator Intelligence |
+| FR3 | Feature Request 3 β Field Operations |
+| FR4 | Feature Request 4 β Remote Node Administration |
+
+---
+
+## Review status (your checklist)
+
+Use this table to track what is **done**, **in flight**, or **not started**. Update the Status column as PRs merge.
+
+| # | PR | Branch | Source | Risk | Status | Upstream PR |
+|---|-----|--------|--------|------|--------|-------------|
+| 01 | Packet Detail Modal | `feat/packet-detail-modal` | FR1 | π’ | β
Submitted | [#68](https://github.com/KMX415/meshpoint/pull/68) |
+| 02 | Config QR Export | `feat/config-qr-export` | FR3 | π’ | β
Submitted | [#76](https://github.com/KMX415/meshpoint/pull/76) |
+| 03 | 24-Hour Traffic Histograms | `feat/traffic-histograms` | FR1 | π’ | β
Submitted | [#69](https://github.com/KMX415/meshpoint/pull/69) |
+| 04 | LAN Browser Push Notifications | `feat/lan-push-notifications` | FR3 | π’ | β
Submitted | [#77](https://github.com/KMX415/meshpoint/pull/77) |
+| 05 | Per-Node Signal Health Sparklines | `feat/signal-health-tracking` | FR1 | π’ | β
Submitted | [#70](https://github.com/KMX415/meshpoint/pull/70) |
+| 06 | Node Coverage Map Layer | `feat/node-coverage-map` | FR2 | π’ | β
Submitted | [#78](https://github.com/KMX415/meshpoint/pull/78) |
+| 07 | Spectrum Analyzer Dashboard Tab | `feat/spectrum-analyzer-tab` | FR2 | π‘ | β
Submitted | [#79](https://github.com/KMX415/meshpoint/pull/79) |
+| 08 | Stray Frame Logger | `feat/stray-frame-logger` | FR3 | π‘ | β
Submitted | [#80](https://github.com/KMX415/meshpoint/pull/80) |
+| 09 | Prometheus Metrics Endpoint | `feat/prometheus-metrics` | FR2 | π‘ | β
Submitted | [#81](https://github.com/KMX415/meshpoint/pull/81) |
+| 10 | Event-Driven Webhook Engine | `feat/webhook-engine-config` | FR3 | π‘ | β
Submitted | [#82](https://github.com/KMX415/meshpoint/pull/82) |
+| 11 | Webhook Dashboard UI | `feat/webhook-ui` | FR3 | π’ | β
Submitted | [#83](https://github.com/KMX415/meshpoint/pull/83) |
+| 12 | Storm Guard Auto-Quarantine | `feat/storm-guard-quarantine` | FR2 | π΄ | β
Submitted | [#84](https://github.com/KMX415/meshpoint/pull/84) |
+| 13 | Smart Relay Filter Controls | `feat/relay-filter-controls` | FR1 | π΄ | β
Submitted | [#71](https://github.com/KMX415/meshpoint/pull/71) |
+| 14 | USB Companion Firmware Flasher | `feat/companion-firmware-flasher` | FR3 | π‘ | β
Submitted | [#85](https://github.com/KMX415/meshpoint/pull/85) |
+| 15 | Remote Node Config Reader | `feat/remote-node-config-read` | FR4 | π΄ | β
Submitted | [#86](https://github.com/KMX415/meshpoint/pull/86) |
+| 16 | Remote Node Config Writer | `feat/remote-node-config-write` | FR4 | π΄ | β
Submitted | [#87](https://github.com/KMX415/meshpoint/pull/87) |
+
+**Legend:** β
Submitted Β· π In review Β· β¬ Not started Β· βΈ Blocked (waiting on dependency)
+
+### Also submitted (parallel diagnostics track β not in the 13-PR queue)
+
+These were built in the earlier [7-PR diagnostics suite](./operator-diagnostics-pr-list.md). They are **in scope** for Meshpoint but numbered separately in that plan:
+
+| Diagnostics PR | Branch | Upstream PR | Notes |
+|----------------|--------|-------------|-------|
+| 5 β Visual Topology Graph | `feat/topology-graph-tab` | [#72](https://github.com/KMX415/meshpoint/pull/72) | FR2-adjacent; map/topology |
+| 6 β LAN REST Automation API | `feat/lan-rest-api` | [#73](https://github.com/KMX415/meshpoint/pull/73) | FR3-adjacent; Home Assistant |
+| 7 β Relay Duty-Cycle Throttle | `feat/relay-duty-cycle` | [#74](https://github.com/KMX415/meshpoint/pull/74) | π΄ relay; depends on PR 03 + 13 logic |
+
+**Recommended:** Let #68β#71 merge before opening #02β#12. Rebase any in-flight branch onto `main` after each merge.
+
+### v0.7.8 operator polish (after Tier A / v0.7.7)
+
+Green-tier operator UX that does not block the 16-PR queue. Full detail:
+[`v0.7.8-operator-polish.md`](./v0.7.8-operator-polish.md).
+
+| # | Branch | Risk | Status | Notes |
+|---|--------|------|--------|-------|
+| 17 | `feat/operator-polish` | π’ | β
[#90](https://github.com/KMX415/meshpoint/pull/90) | Status strips (Stats), MQTT runtime health, map GPS hint, HA cookbook |
+| 18 | `feat/stats-traffic-heatmap` | π’ | π In progress | After **#69** merges; branches from `feat/traffic-histograms` |
+| 19 | `feat/analytics-status-strips` | π’ | β
Partial | Footers on **#72, #79, #80**; Stats footer in **#90** |
+| 20 | `docs/home-assistant-cookbook` | Docs | In #17 | Standalone doc also linked from README when #17 lands |
+
+Submit **17** only after Tier A PRs begin landing. Never open 17 and 18 at once.
+
+---
+
+## Master table (submit in this order)
+
+| # | Branch | Source | Risk | Relay? | Backend? | Frontend? | Depends on |
+|---|--------|--------|------|--------|----------|-----------|------------|
+| 01 | `feat/packet-detail-modal` | FR1 | π’ | No | No | Yes | β |
+| 02 | `feat/config-qr-export` | FR3 | π’ | No | Minimal | Yes | β |
+| 03 | `feat/traffic-histograms` | FR1 | π’ | No | One endpoint | Yes | β |
+| 04 | `feat/lan-push-notifications` | FR3 | π’ | No | One WS field | Yes | β |
+| 05 | `feat/signal-health-tracking` | FR1 | π’ | No | One endpoint | Yes | β |
+| 06 | `feat/node-coverage-map` | FR2 | π’ | No | One endpoint | Yes | β |
+| 07 | `feat/spectrum-analyzer-tab` | FR2 | π‘ | No | One endpoint | Yes | β |
+| 08 | `feat/stray-frame-logger` | FR3 | π‘ | No | Table + insert | Yes | β |
+| 09 | `feat/prometheus-metrics` | FR2 | π‘ | No | One endpoint | No | β |
+| 10 | `feat/webhook-engine-config` | FR3 | π‘ | No | New module | No | β |
+| 11 | `feat/webhook-ui` | FR3 | π’ | No | Two endpoints | Yes | **PR 10** |
+| 12 | `feat/storm-guard-quarantine` | FR2 | π΄ | Yes | Rate counter | Yes | [#84](https://github.com/KMX415/meshpoint/pull/84) |
+| 13 | `feat/relay-filter-controls` | FR1 | π΄ | Yes | Config R/W | Yes | β |
+| 14 | `feat/companion-firmware-flasher` | FR3 | π‘ | No | Subprocess + WS | Yes | β |
+| 15 | `feat/remote-node-config-read` | FR4 | π΄ | Yes | ADMIN TX read | Yes | **PR 14** |
+| 16 | `feat/remote-node-config-write` | FR4 | π΄ | Yes | ADMIN TX write | Yes | **PR 15** + 1 wk soak |
+
+**Merge order:** 01 β 02 β β¦ β 16 (ascending risk within each phase). **Never open two PRs at once.**
+
+**Field hardware suite (14β16):** Submit **14**, wait for merge, then **15**, wait for merge + **β₯1 week production soak**, then **16**.
+
+---
+
+## Shared prerequisites (every PR)
+
+```bash
+git fetch upstream
+git checkout -B feat/ upstream/main
+```
+
+**PR description must include:**
+
+- What / why / how tested
+- Hardware (RAK7248 or Pi + RAK2287/5146 HAT) and region (US915; EU868 where relevant)
+- Risks (especially π΄ relay PRs)
+- `Closes #NNN` or `Related to #NNN`
+- AI disclosure line (see [Submission notes](#submission-notes))
+
+**Suggested GitHub issue (umbrella):**
+
+> `[Feature] Master roadmap β diagnostics, intelligence, field operations, and remote admin (16 PRs)`
+
+---
+
+## PR 01 β Packet Detail Modal
+
+| | |
+|---|---|
+| **Branch** | `feat/packet-detail-modal` |
+| **Title** | `feat(dashboard): packet detail modal in live feed` |
+| **Source** | FR1 |
+| **Risk** | π’ Frontend only |
+| **Status** | β
[#68](https://github.com/KMX415/meshpoint/pull/68) |
+
+### Scope
+
+Click any row in the live packet feed β layered modal (RF, mesh header, decoded payload, capture meta). Uses existing WebSocket `Packet.to_dict()` β **no new API**.
+
+### Files (codebase-accurate)
+
+| Layer | Files |
+|-------|-------|
+| Frontend | `frontend/js/simple_packet_feed.js` |
+| Frontend | `frontend/js/packet_detail_modal.js` β **new** |
+| Frontend | `frontend/css/packet_detail_modal.css` β **new** |
+| Frontend | `frontend/index.html` β script/style tags |
+
+*Roadmap text said `PacketFeed.js` β actual feed module is `simple_packet_feed.js`.*
+
+### Done when
+
+- [ ] Modal layers: RF, mesh, payload/decrypt, capture
+- [ ] Decrypt-failure state explicit
+- [ ] Close: β, Escape, backdrop
+- [ ] Feed updates behind open modal
+- [ ] Tested on target hardware
+
+---
+
+## PR 02 β Config QR Export
+
+| | |
+|---|---|
+| **Branch** | `feat/config-qr-export` |
+| **Title** | `feat(config): Quick Deploy QR export of public channel params` |
+| **Source** | FR3 |
+| **Risk** | π’ One read-only endpoint + frontend |
+| **Status** | β¬ Not started |
+
+### Scope
+
+"Quick Deploy" generates QR + downloadable JSON of **public** channel parameters (name, frequency, modem preset). **PSKs never included.** QR format matches Meshtastic channel URL scheme.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/api/routes/config_routes.py` (or new `export_routes.py`) β `GET /api/config/export` |
+| Frontend | New QR panel (Configuration or Radio tab) + QR library (CDN or vendored) |
+| Docs | `docs/CONFIGURATION.md` β export section |
+
+### Endpoint (proposed)
+
+```
+GET /api/config/export
+β { channel_name, frequency_mhz, region, modem_preset, meshtastic_url, ... }
+```
+
+Read from existing `AppConfig` / `GET /api/config` enrichment β no secrets.
+
+### Done when
+
+- [ ] Response contains zero PSK / `channel_keys` material
+- [ ] QR scans correctly in Meshtastic app
+- [ ] JSON download works offline
+- [ ] Auth: same as other config routes (`require_auth`)
+
+---
+
+## PR 03 β 24-Hour Traffic Histograms
+
+| | |
+|---|---|
+| **Branch** | `feat/traffic-histograms` |
+| **Title** | `feat(stats): 24-hour hourly traffic histogram with duty-cycle estimate` |
+| **Source** | FR1 |
+| **Risk** | π’ Frontend + one read-only SQL query |
+| **Status** | β
[#69](https://github.com/KMX415/meshpoint/pull/69) |
+
+### Scope
+
+Hourly packet volume (24h), stacked Meshtastic/MeshCore, duty-cycle overlay (est.). EU868 1% limit line when `radio.region == EU_868`.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/storage/packet_repository.py` β `get_hourly_traffic()` |
+| Backend | `src/api/routes/stats_routes.py` β `GET /api/stats/hourly` |
+| Backend | `src/analytics/toa_estimate.py` β shared ToA helper |
+| Frontend | `frontend/js/stats_tab.js`, `frontend/css/stats.css` |
+| Tests | `tests/test_traffic_hourly.py` |
+
+### Done when
+
+- [ ] Hourly counts match manual SQL spot-check
+- [ ] Duty line labeled **est.**
+- [ ] EU868 limit line only when region matches
+- [ ] No schema migration (uses `idx_packets_timestamp`)
+
+---
+
+## PR 04 β LAN Browser Push Notifications
+
+| | |
+|---|---|
+| **Branch** | `feat/lan-push-notifications` |
+| **Title** | `feat(dashboard): LAN browser push notifications for mesh alerts` |
+| **Source** | FR3 |
+| **Risk** | π’ Frontend + one additive WS field |
+| **Status** | β
[#77](https://github.com/KMX415/meshpoint/pull/77) |
+
+### Scope
+
+Native browser notifications via Service Worker when operator-selected events occur (node offline/online, battery low, storm-guard trigger). Preferences in `localStorage`. Works while any dashboard tab is open.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | WS broadcast schema β add optional `event_type: "alert"` (additive, non-breaking) |
+| Frontend | Service worker β **new** (`frontend/sw.js` or similar) |
+| Frontend | Notification settings UI (Configuration or global settings) |
+| Frontend | `frontend/js/app.js` β WS handler for alerts |
+
+### Depends on
+
+- Nothing for basic alerts
+- Storm-guard **content** needs PR 12 to emit quarantine alerts (can ship PR 04 with other triggers first)
+
+### Done when
+
+- [ ] Permission prompt + per-event toggles
+- [ ] Existing WS packet stream unchanged when alerts disabled
+- [ ] No notification when tab focused (optional UX)
+- [ ] Tested in Chromium + Firefox
+
+---
+
+## PR 05 β Per-Node Signal Health Sparklines
+
+| | |
+|---|---|
+| **Branch** | `feat/signal-health-tracking` |
+| **Title** | `feat(nodes): RSSI sparklines and health badges on node cards` |
+| **Source** | FR1 |
+| **Risk** | π’ Frontend + read-only SQL per node |
+| **Status** | β
[#70](https://github.com/KMX415/meshpoint/pull/70) |
+
+### Scope
+
+RSSI/SNR sparkline on node cards (24h, 15-min buckets), expandable in node drawer. Green/yellow/red badge from configurable thresholds in `local.yaml`. Lazy-loaded (`IntersectionObserver`).
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/storage/packet_repository.py` β `get_signal_buckets()` |
+| Backend | `src/api/routes/nodes.py` β extend `GET /api/nodes/{id}/metrics_history?bucket_minutes=15` |
+| Config | `src/config.py` β `SignalHealthConfig` (optional) |
+| Frontend | `frontend/js/signal_sparkline.js`, `frontend/js/node_cards.js` |
+| Tests | `tests/test_signal_buckets.py` |
+
+*Roadmap proposed `/signal_history` β **extend existing** `metrics_history` instead of a parallel endpoint.*
+
+### Done when
+
+- [ ] Sparkline loads only when card visible
+- [ ] Badge hidden when packet count below `min_packets_per_hour`
+- [ ] Thresholds documented in `CONFIGURATION.md`
+
+---
+
+## PR 06 β Node Coverage Map Layer
+
+| | |
+|---|---|
+| **Branch** | `feat/node-coverage-map` |
+| **Title** | `feat(map): RSSI coverage circles on dashboard map` |
+| **Source** | FR2 |
+| **Risk** | π’ Frontend + one read-only SQL JOIN |
+| **Status** | β
[#78](https://github.com/KMX415/meshpoint/pull/78) |
+
+### Scope
+
+"Coverage View" toggle on existing Leaflet map. Nodes with GPS β color-coded circles (RSSI quality), size β packet confidence. Nodes without GPS β "unplotted" count in sidebar. Click marker β existing node drawer.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/api/routes/nodes.py` β `GET /api/nodes/coverage` (JOIN nodes + packet aggregates) |
+| Backend | `src/storage/node_repository.py` / `packet_repository.py` β aggregation helper |
+| Frontend | `frontend/js/components/node_map.js` β coverage layer toggle |
+| Frontend | `frontend/css/` β circle layer styles |
+
+*Existing map: `NodeMap` in `node_map.js`; data today via `GET /api/nodes/map` β `NetworkMapper.get_map_data()`.*
+
+### Done when
+
+- [ ] Toggle does not break topology layer (PR diagnostics #5)
+- [ ] Unplotted count accurate
+- [ ] No new tile server
+- [ ] Performance OK with 500+ nodes
+
+---
+
+## PR 07 β Spectrum Analyzer Dashboard Tab
+
+| | |
+|---|---|
+| **Branch** | `feat/spectrum-analyzer-tab` |
+| **Title** | `feat(rf): RF Environment tab exposing spectral scan + noise floor` |
+| **Source** | FR2 |
+| **Risk** | π‘ New endpoint reading existing services |
+| **Status** | β
[#79](https://github.com/KMX415/meshpoint/pull/79) |
+
+### Scope
+
+New **RF Environment** tab: noise-floor sparkline + calibration state; spectral histogram from latest scan. **Exposure only** β backend services already exist and are tested.
+
+### Already in repo
+
+| Component | Path |
+|-----------|------|
+| `NoiseFloorTracker` | `src/api/telemetry/noise_floor.py` |
+| `SpectralScanService` | `src/api/telemetry/spectral_scan_service.py` |
+| HAL scan | `src/hal/sx1302_spectral_scan.py` |
+| Tests | `tests/test_noise_floor.py`, `tests/test_spectral_scan_*.py` |
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/api/routes/rf_routes.py` β **new** β `GET /api/rf/status` |
+| Backend | `src/api/server.py` β wire tracker + scan service into route init |
+| Frontend | New RF tab JS + CSS; sidebar entry in `frontend/index.html` |
+
+### Done when
+
+- [ ] Tab shows "no hardware scan" gracefully when `spectral_scan_interval_seconds: 0`
+- [ ] Values labeled live vs packet-derived fallback
+- [ ] No new HAL code unless bugfix required
+
+---
+
+## PR 08 β Stray Frame Logger
+
+| | |
+|---|---|
+| **Branch** | `feat/stray-frame-logger` |
+| **Title** | `feat(capture): log undecodable RF frames to stray_frames table` |
+| **Source** | FR3 |
+| **Risk** | π‘ New SQLite table + receive-path insert |
+| **Status** | β
Submitted ([#80](https://github.com/KMX415/meshpoint/pull/80)) |
+
+### Scope
+
+When a frame fails **both** Meshtastic and MeshCore decode, persist RF metadata only (channel, frequency, SF, BW, RSSI, SNR, frame size) to `stray_frames`. Dashboard **Unknown RF** tab with filter + CSV export. Auto-prune by retention hours.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Storage | `src/storage/database.py` β migration for `stray_frames` |
+| Storage | `src/storage/stray_frame_repository.py` β **new** |
+| Pipeline | Insert after decode failure, before discard (coordinator / packet router) |
+| API | `GET /api/stray_frames` (+ optional `?format=csv`) |
+| Frontend | New tab or Stats subsection |
+| Config | `stray_frames.max_retained` / `retention_hours` in `local.yaml` |
+
+### Done when
+
+- [ ] Insertion is **after** both decoders return None β no TX, no relay, no re-encode
+- [ ] CSV export works
+- [ ] Prune job does not block receive loop
+- [ ] Unit test for repository + insert hook
+
+---
+
+## PR 09 β Prometheus Metrics Endpoint
+
+| | |
+|---|---|
+| **Branch** | `feat/prometheus-metrics` |
+| **Title** | `feat(metrics): Prometheus-compatible /metrics endpoint` |
+| **Source** | FR2 |
+| **Risk** | π‘ New endpoint + optional config |
+| **Status** | β
Submitted ([#81](https://github.com/KMX415/meshpoint/pull/81)) |
+
+### Scope
+
+`GET /metrics` in Prometheus text format: packet counts, duty-cycle est., noise floor, node counts, RSSI averages, CRC errors, relay stats, uptime. Read from in-memory state. **Disabled by default** (`metrics.enabled: false`). Optional `metrics.require_auth`.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/api/routes/metrics_routes.py` β **new** |
+| Config | `src/config.py` β `MetricsConfig` |
+| Docs | `docs/CONFIGURATION.md` |
+
+**Implementation choice (decide before coding):**
+
+- **A)** Zero-dep raw `# HELP` / `# TYPE` writer (preferred for Pi installs)
+- **B)** Optional `prometheus_client` dependency
+
+### Done when
+
+- [ ] Default off β zero behaviour change on upgrade
+- [ ] Document scrape config for LAN Prometheus
+- [ ] No PSK / token values in labels
+
+---
+
+## PR 10 β Event-Driven Webhook Engine (Config Only)
+
+| | |
+|---|---|
+| **Branch** | `feat/webhook-engine-config` |
+| **Title** | `feat(webhooks): configurable outbound HTTP rules for mesh events` |
+| **Source** | FR3 |
+| **Risk** | π‘ New async module + yaml schema |
+| **Status** | β
Submitted ([#82](https://github.com/KMX415/meshpoint/pull/82)) |
+
+### Scope
+
+`local.yaml` webhook rules β async HTTP POST on events (battery low, node silence/return, keyword match, duty spike, storm quarantine). Per-rule cooldown in memory. Failures never block packet processing. Fires logged to audit log.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/webhook/engine.py` β **new** (or `src/integrations/webhook_engine.py`) |
+| Config | `src/config.py` β `WebhookConfig` / rules list |
+| Wiring | `src/coordinator.py` or packet callbacks β subscribe to decoded stream |
+| Docs | `docs/CONFIGURATION.md` |
+
+Uses `httpx` (already in stack). **No relay / TX / decoder changes.**
+
+### Depends on
+
+- Storm-guard **trigger** needs PR 12; all other triggers work independently
+
+### Done when
+
+- [ ] Failed POST does not slow pipeline
+- [ ] Cooldown prevents alert storms
+- [ ] Rules validated at startup
+- [ ] Unit tests with mocked HTTP
+
+---
+
+## PR 11 β Webhook Dashboard UI
+
+| | |
+|---|---|
+| **Branch** | `feat/webhook-ui` |
+| **Title** | `feat(dashboard): webhook rules status and test panel` |
+| **Source** | FR3 |
+| **Risk** | π’ Frontend + thin API |
+| **Status** | β
Submitted ([#83](https://github.com/KMX415/meshpoint/pull/83)) |
+| **Depends on** | **PR 10 merged** ([#82](https://github.com/KMX415/meshpoint/pull/82)) |
+
+### Scope
+
+Settings panel: active rules from config, last-fired timestamps, **Test** button (dummy POST to verify URL from Pi).
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `GET /api/webhooks/status`, `POST /api/webhooks/test/{rule_name}` |
+| Frontend | Configuration card β **new** |
+
+### Done when
+
+- [ ] Test POST clearly marked dummy payload
+- [ ] Last-fired updates without restart
+- [ ] No rule secrets in API response
+
+---
+
+## PR 12 β Storm Guard Auto-Quarantine
+
+| | |
+|---|---|
+| **Branch** | `feat/storm-guard-quarantine` |
+| **Title** | `feat(relay): automatic storm/replay quarantine with auto-release` |
+| **Source** | FR2 |
+| **Risk** | π΄ Relay module β **extra review** |
+| **Status** | β
[#84](https://github.com/KMX415/meshpoint/pull/84) |
+
+### Scope
+
+Temporary **memory-only** quarantine for storm/replay behavior:
+
+- Identical `packet_id` seen N times in rolling window, **or**
+- Excessive packets/minute from one node
+
+Separate from permanent blocklist (PR 13). Auto-release after duration. Amber badge + countdown on node cards. Operator can release early or promote to blocklist.
+
+### Config (proposed β under `relay` section)
+
+```yaml
+relay:
+ storm_guard:
+ enabled: true
+ window_seconds: 60
+ identical_packet_threshold: 5
+ rate_threshold_per_minute: 30
+ quarantine_duration_seconds: 300
+ notify_dashboard: true
+```
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Relay | `src/relay/storm_guard.py` β **new** |
+| Relay | `src/relay/relay_manager.py` β check quarantine before approve |
+| API | Optional `GET /api/relay/quarantine` for dashboard |
+| Frontend | Node card badge; Relay / Stats section |
+| WS | Optional `event_type: alert` for PR 04 |
+
+### Risks (call out in PR)
+
+- False positive quarantine on busy legitimate nodes
+- Memory growth if quarantine dict not pruned
+- Distinction from blocklist must be documented
+
+### Done when
+
+- [ ] Quarantine does not write SQLite
+- [ ] Auto-release tested with clock injection
+- [ ] Tested against fast TX test node
+- [ ] `channel_throttled` / blocklist interaction documented
+
+---
+
+## PR 13 β Smart Relay Filter Controls
+
+| | |
+|---|---|
+| **Branch** | `feat/relay-filter-controls` |
+| **Title** | `feat(relay): dashboard blocklist, priority list, and dedup TTL` |
+| **Source** | FR1 |
+| **Risk** | π΄ Relay + config β **extra review** |
+| **Status** | β
[#71](https://github.com/KMX415/meshpoint/pull/71) |
+
+### Scope
+
+Dashboard UI for relay filters without editing `local.yaml`. Blocklist, priority list, dedup TTL. Hot-reload without restart.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Config | `src/config.py` β `relay.blocklist`, `priority_list`, `dedup_ttl_seconds` |
+| Relay | `src/relay/relay_manager.py` β `reload_filters()` |
+| API | `PUT /api/config/relay` in `system_config_routes.py` |
+| Frontend | `frontend/js/configuration/relay_filters_card.js` |
+| Tests | `tests/test_relay_filters.py` |
+
+*Roadmap used flat keys `relay_blocklist` β actual schema nests under `relay:` in `local.yaml`.*
+
+### Done when
+
+- [ ] Blocklist is relay-only β feed unchanged
+- [ ] Priority bypasses burst gate only (not per-minute cap)
+- [ ] Node ID validated server-side (8 hex chars)
+- [ ] Persistence across restart via `save_section_to_yaml`
+
+---
+
+## PR 14 β USB Companion Firmware Flasher
+
+| | |
+|---|---|
+| **Branch** | `feat/companion-firmware-flasher` |
+| **Title** | `feat(hardware): USB companion firmware flasher with live esptool output` |
+| **Source** | FR3 |
+| **Risk** | π‘ Subprocess + WebSocket β **no radio TX** |
+| **Status** | β
Submitted β [#85](https://github.com/KMX415/meshpoint/pull/85) |
+
+### Scope
+
+Upload a `.bin` from **Settings β System** and flash the USB MeshCore/Meshtastic companion via `esptool`. Live log stream on authenticated WebSocket. MeshCore USB releases serial before flash; auto-reconnect after ESP reboot.
+
+### Files
+
+| Layer | Files |
+|-------|-------|
+| Backend | `src/firmware/flasher.py`, `upload_store.py`, `log_broadcast.py` |
+| Backend | `src/api/routes/firmware_routes.py` |
+| Backend | `src/capture/meshcore_usb_source.py` β `release_serial_for_flash()` |
+| Backend | `src/api/server.py` β router + MeshCore suspend hook |
+| Frontend | `frontend/js/settings/companion_flash_card.js` |
+| Deps | `requirements.txt` β `esptool>=4.7.0` |
+| Tests | `tests/test_firmware_flasher.py` |
+| Docs | `docs/CONFIGURATION.md` β companion flash section |
+
+### Done when
+
+- [x] Admin-only upload + flash; audit `firmware_flash`
+- [x] WS uses `_gate_ws_or_close` pattern (4401 when unauthenticated)
+- [x] Per-port lock; HTTP 409 on concurrent flash
+- [x] Temp upload cleaned up on all paths
+- [x] DangerousModal confirmation before flash POST
+- [ ] Hardware flash on RAK7248 + Heltec companion
+
+---
+
+## PR 15 β Remote Node Config Reader (ADMIN, read-only)
+
+| | |
+|---|---|
+| **Branch** | `feat/remote-node-config-read` |
+| **Title** | `feat(admin): remote Meshtastic node config reader via ADMIN portnum` |
+| **Source** | FR4 |
+| **Risk** | π΄ TX path β one ADMIN request per click |
+| **Status** | β
Submitted β [#86](https://github.com/KMX415/meshpoint/pull/86) |
+
+### Scope
+
+**Request config** in node detail drawer. Sends encrypted `get_config_request` ADMIN packet; displays decoded response read-only. Requires `meshtastic.admin_key_b64` in `local.yaml`.
+
+### Done when
+
+- [x] No write path in this PR
+- [x] 30 s timeout + retry UI; 30 s button debounce
+- [x] PSK redacted in audit log
+- [ ] Tested 1-hop and 3-hop on hardware
+
+---
+
+## PR 16 β Remote Node Config Writer (ADMIN write)
+
+| | |
+|---|---|
+| **Branch** | `feat/remote-node-config-write` |
+| **Title** | `feat(admin): remote Meshtastic node config write (limited fields)` |
+| **Source** | FR4 |
+| **Risk** | π΄ **Highest in roadmap** β write TX + crypto |
+| **Status** | β
Submitted β [#87](https://github.com/KMX415/meshpoint/pull/87) |
+
+### Scope
+
+Editable fields: names, telemetry interval, screen timeout, device role (typed `CONFIRM`). Post-write automatic config read to verify. No frequency/PSK/factory reset.
+
+### Done when
+
+- [x] Role change requires second confirmation
+- [x] Follow-up read verifies applied state
+- [ ] Hardware: role + telemetry interval change verified
+
+---
+
+## Submission strategy
+
+| Phase | PRs | Goal |
+|-------|-----|------|
+| **A β Green baseline** | 01β06 (skip done: 01, 03, 05) | Establish clean contribution pattern |
+| **B β Yellow backend** | 07β09 | After β₯2 green merges |
+| **C β Webhooks** | 10 β 11 | Engine reviewed before UI |
+| **D β Relay last** | 12 β 13 | Highest review; 13 already submitted β merge before PR 12 if both open |
+| **E β Field hardware** | 14 β 15 β 16 | 14 no TX; 15β16 ADMIN TX; **1 week soak** between 15 and 16 |
+
+**Never submit two PRs simultaneously.**
+
+After each merge:
+
+```bash
+git fetch upstream
+git checkout -B feat/ upstream/main
+```
+
+---
+
+## Scope alignment notes (roadmap vs repo)
+
+| Roadmap item | Repo reality |
+|--------------|--------------|
+| `PacketFeed.js` | `frontend/js/simple_packet_feed.js` |
+| `/api/nodes/{id}/signal_history` | Extend `GET /api/nodes/{id}/metrics_history` |
+| `relay_blocklist` top-level keys | `relay.blocklist` under `relay:` in yaml |
+| `relay_duplicate_window_sec: 30` | Implemented as `relay.dedup_ttl_seconds` (default 300) |
+| `src/api.py` | Routes live under `src/api/routes/*.py` |
+| Topology graph | Already PR diagnostics #5 ([#72](https://github.com/KMX415/meshpoint/pull/72)) β not in 13-PR table |
+| Duty-cycle throttle | Already PR diagnostics #7 ([#74](https://github.com/KMX415/meshpoint/pull/74)) β after PR 03 + 13 |
+
+---
+
+## Operator test matrix (all PRs)
+
+| Item | Value |
+|------|-------|
+| Primary hardware | RAK Hotspot V2 (RAK7248) or Pi 4 + RAK2287/5146 Pi HAT |
+| Region | US915 primary; EU868 re-test for PR 03 duty line + PR 12/13 |
+| OS | Raspberry Pi OS 64-bit Bookworm |
+| Load profile | ~8kβ12k packets/day backbone |
+| MeshCore | Heltec V3 `companion_radio_usb` optional |
+
+---
+
+## Submission notes
+
+### AI-assisted contributions
+
+Per CONTRIBUTING.md, include in **each** PR description:
+
+> Note: PR description and initial scaffolding assisted by Claude. All code reviewed and tested on target hardware before submission.
+
+### What to say when starting each PR
+
+| Step | Action |
+|------|--------|
+| Start next green PR | `git checkout -B feat/ upstream/main` |
+| After merge | Rebase or recreate branch from fresh `upstream/main` |
+| Relay PR | Request extra review; document insertion point + failure modes |
+
+### Suggested next PR (as of this doc)
+
+With 01β13 submitted (02 also submitted as #76) and **14β16** queued:
+
+1. **Waiting:** PR **16** [#87](https://github.com/KMX415/meshpoint/pull/87) in review β hardware role + telemetry verify
+2. **Waiting:** PR **15** [#86](https://github.com/KMX415/meshpoint/pull/86) in review β hardware read 1-hop + 3-hop
+3. **Waiting:** PR **14** [#85](https://github.com/KMX415/meshpoint/pull/85) in review (companion flash)
+4. **Roadmap E complete** once 14β16 merge; soak #15 before #16 merge per strategy
+4. Rebase open relay/webhook PRs (#80β#84) onto `main` as merges land
+
+---
+
+## Quick review checklist (per PR before submit)
+
+- [ ] Branch from latest `upstream/main` (not stacked on another feature branch)
+- [ ] Single focused change; no drive-by refactors
+- [ ] Tests added or extended where backend logic changes
+- [ ] `docs/CONFIGURATION.md` updated if new yaml keys
+- [ ] No secrets in repo, logs, or API responses
+- [ ] PR body: what / why / test plan / hardware / region / risks
+- [ ] AI disclosure line present
+- [ ] π΄ Relay PRs: explicit "does not change TX to mesh" vs relay-only behaviour
diff --git a/docs/plans/v0.7.8-operator-polish.md b/docs/plans/v0.7.8-operator-polish.md
new file mode 100644
index 00000000..5b35d0bc
--- /dev/null
+++ b/docs/plans/v0.7.8-operator-polish.md
@@ -0,0 +1,95 @@
+# v0.7.8 Operator Polish
+
+**Purpose:** Small, green-tier improvements that help field operators trust
+dashboard data and integrate Meshpoint with LAN automation. Submit **after**
+Tier A (v0.7.7) PRs begin merging; **one PR at a time**.
+
+**Risk:** π’ Frontend + read-only backend surfaces.
+
+---
+
+## Queue
+
+| # | Branch | Title | Depends on | Status |
+|---|--------|-------|------------|--------|
+| 17 | `feat/operator-polish` | Operator status strips + MQTT broker health + map hint | Tier A baseline | β
[#90](https://github.com/KMX415/meshpoint/pull/90) |
+| 18 | `feat/stats-traffic-heatmap` | SVG traffic heatmap (extends #69 histogram data) | **#69 merged** | π Branch ready (draft) |
+| 19 | `feat/analytics-status-strips` | RF + Unknown RF + Topology tab footers | **#72, #79, #80** | β
On #72, #79, #80 |
+| 20 | `docs/home-assistant-cookbook` | Expanded HA recipes (ships with #17 docs) | β | β
In #17 |
+
+---
+
+## PR 17 β Operator polish (green)
+
+| | |
+|---|---|
+| **Branch** | `feat/operator-polish` |
+| **Title** | `feat(dashboard): operator status strips and MQTT broker health` |
+| **Upstream PR** | [#90](https://github.com/KMX415/meshpoint/pull/90) |
+| **Risk** | π’ |
+
+### What ships
+
+1. **Status strip component** (`status_strip.js`, `status_strip.css`)
+ - Console-style footer: `TRAFFIC Β· concentrator Β· 8,247 pkts Β· updated 14s ago`
+ - Wired on Stats tab (more tabs in PR 19 after their parent PRs merge)
+
+2. **MQTT broker health** (`GET /api/config/mqtt/runtime`)
+ - Live connection, publish count, disconnect count, topic prefix
+ - Configuration β MQTT card banner (no restart required to view)
+
+3. **Map topology GPS hint**
+ - Amber banner when Topology Links overlay is on but no lines draw
+ - Points operators to Topology tab for logical graph
+
+4. **Home Assistant cookbook** (`docs/HOME-ASSISTANT-COOKBOOK.md`)
+
+### Test plan
+
+- [ ] Stats tab footer updates on refresh
+- [ ] MQTT card shows connected/disabled/offline states
+- [ ] Map hint appears when links exist without GPS on both ends
+- [ ] `python -m unittest tests.test_mqtt_publisher tests.test_mqtt_config_routes`
+
+---
+
+## PR 18 β Traffic heatmap (green, follow-up to #69)
+
+Do **not** rewrite #69 mid-merge. After #69 lands, open PR from
+`feat/stats-traffic-heatmap` (branched from `feat/traffic-histograms`).
+
+Adds an SVG hour Γ protocol heatmap under the 24h bar chart using
+`GET /api/stats/hourly` data already added by [#69](https://github.com/KMX415/meshpoint/pull/69).
+No new backend endpoints.
+
+---
+
+## PR 19 β Analytics tab footers (green)
+
+`StatusStrip` footers are already on open PRs [#72](https://github.com/KMX415/meshpoint/pull/72),
+[#79](https://github.com/KMX415/meshpoint/pull/79), and
+[#80](https://github.com/KMX415/meshpoint/pull/80). Stats footer ships in #90.
+
+Reference labels:
+
+| Tab | Label | Example |
+|-----|-------|---------|
+| Topology | `TOPOLOGY` | `47 nodes Β· 23 links Β· neighborinfo+routing Β· updated 2m ago` |
+| RF Environment | `RF ENV` | `noise -112 dBm Β· live scan Β· 48 samples Β· updated 5s ago` |
+| Unknown RF | `UNKNOWN RF` | `12 frames Β· 3 SF buckets Β· updated 1m ago` |
+
+Rebase each tab's branch and add ~20 lines; or land as one PR on top of
+merged analytics tabs.
+
+---
+
+## Submission order
+
+```
+Tier A merges (v0.7.7)
+ β PR 17 operator-polish
+ β wait for #69 merge β PR 18 heatmap
+ β wait for #72/#79/#80 β PR 19 tab footers
+```
+
+Never open PR 17 and PR 18 simultaneously.
diff --git a/frontend/css/status_strip.css b/frontend/css/status_strip.css
new file mode 100644
index 00000000..aa3dc0c4
--- /dev/null
+++ b/frontend/css/status_strip.css
@@ -0,0 +1,105 @@
+/* Operator status footer β console-style telemetry in panel chrome. */
+
+.status-strip {
+ display: flex;
+ align-items: baseline;
+ flex-wrap: wrap;
+ gap: 0.35rem;
+ margin-top: 1.25rem;
+ padding: 0.5rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(255, 255, 255, 0.04);
+ background: rgba(6, 182, 212, 0.03);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+}
+
+.status-strip__label {
+ color: var(--text-secondary);
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ font-size: 0.62rem;
+}
+
+.status-strip__sep {
+ opacity: 0.35;
+}
+
+.status-strip__items {
+ color: var(--accent-cyan);
+ text-shadow: 0 0 6px rgba(6, 182, 212, 0.12);
+}
+
+.status-strip__updated {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.status-strip--quiet .status-strip__items {
+ color: var(--text-muted);
+ text-shadow: none;
+ font-style: italic;
+}
+
+.map-topology-hint {
+ position: absolute;
+ left: 50%;
+ bottom: 2.75rem;
+ transform: translateX(-50%);
+ z-index: 800;
+ max-width: min(92%, 28rem);
+ padding: 0.45rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(245, 158, 11, 0.35);
+ background: rgba(15, 23, 42, 0.92);
+ color: var(--accent-amber);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.03em;
+ text-align: center;
+ pointer-events: none;
+ box-shadow: var(--glow-amber);
+}
+
+.mqtt-runtime {
+ margin: 0 0 1rem;
+ padding: 0.55rem 0.75rem;
+ border-radius: var(--radius-sm);
+ border: 1px solid rgba(255, 255, 255, 0.05);
+ background: rgba(0, 229, 160, 0.04);
+ font-family: var(--font-mono);
+ font-size: 0.68rem;
+ letter-spacing: 0.03em;
+ color: var(--text-muted);
+}
+
+.mqtt-runtime--offline {
+ background: rgba(239, 68, 68, 0.06);
+ border-color: rgba(239, 68, 68, 0.25);
+}
+
+.mqtt-runtime--disabled {
+ background: rgba(100, 116, 139, 0.08);
+}
+
+.mqtt-runtime__label {
+ color: var(--text-secondary);
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ font-size: 0.62rem;
+ margin-right: 0.35rem;
+}
+
+.mqtt-runtime__state {
+ color: var(--accent-green);
+}
+
+.mqtt-runtime--offline .mqtt-runtime__state {
+ color: var(--accent-red);
+}
+
+.mqtt-runtime--disabled .mqtt-runtime__state {
+ color: var(--text-muted);
+}
diff --git a/frontend/index.html b/frontend/index.html
index 239398cc..69a41a12 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -21,6 +21,7 @@
+
@@ -747,6 +748,7 @@ Service actions
+
diff --git a/frontend/js/components/node_map.js b/frontend/js/components/node_map.js
index c8a88af8..72564281 100644
--- a/frontend/js/components/node_map.js
+++ b/frontend/js/components/node_map.js
@@ -404,12 +404,33 @@ class NodeMap {
}, 200);
}
+ _showTopologyMapHint(show) {
+ let el = document.getElementById('map-topology-hint');
+ if (!el && this._map) {
+ el = document.createElement('div');
+ el.id = 'map-topology-hint';
+ el.className = 'map-topology-hint';
+ el.hidden = true;
+ this._map.getContainer().appendChild(el);
+ }
+ if (!el) return;
+ if (show) {
+ el.textContent =
+ 'Links need GPS on both endpoints. Open the Topology tab for the logical graph.';
+ el.hidden = false;
+ } else {
+ el.hidden = true;
+ }
+ }
+
async _loadTopology() {
try {
- const res = await fetch('/api/analytics/topology');
- const links = await res.json();
+ const res = await fetch('/api/analytics/topology?hours=24');
+ const data = await res.json();
+ const links = Array.isArray(data) ? data : (data.edges || []);
this._topologyLayer.clearLayers();
+ let drawn = 0;
for (const link of links) {
const srcMarker = this._markers[link.source];
const tgtMarker = this._markers[link.target];
@@ -434,9 +455,14 @@ class NodeMap {
line.bindTooltip(tooltip);
this._topologyLayer.addLayer(line);
+ drawn += 1;
}
+ this._showTopologyMapHint(
+ this._topologyVisible && links.length > 0 && drawn === 0,
+ );
} catch (e) {
console.error('Topology load failed:', e);
+ this._showTopologyMapHint(false);
}
}
diff --git a/frontend/js/configuration/mqtt_card.js b/frontend/js/configuration/mqtt_card.js
index 2678d460..c7738e4c 100644
--- a/frontend/js/configuration/mqtt_card.js
+++ b/frontend/js/configuration/mqtt_card.js
@@ -11,6 +11,7 @@ class MqttConfigCard {
this._root = null;
this._passwordDirty = false;
this._unsubDisplayUnits = null;
+ this._runtimeInterval = null;
}
mount(root) {
@@ -25,6 +26,10 @@ class MqttConfigCard {
are published. Undecrypted packets never leave the device.
+
+ BROKER
+ Loadingβ¦
+