Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,10 @@ dashboard:

Access at `http://<pi-ip>:8080`. Bind to `127.0.0.1` to restrict to local access only.

### Coverage View (map)

On the **Dashboard → Node Map**, open the Leaflet layer control (top-right) and enable **Coverage View**. Plotted nodes with GPS show RSSI-colored circles (green/cyan/yellow/red by average signal). Circle size reflects recent packet volume (confidence). Nodes without coordinates appear in the **unplotted** count beside the map title. Click any marker or coverage circle to open the node drawer. **Topology Links** remains a separate overlay and can be used together with coverage.

---

## Device Identity
Expand Down
35 changes: 35 additions & 0 deletions frontend/css/map_coverage.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* RSSI coverage circles on the dashboard Leaflet map (PR 06). */

.map-unplotted {
margin-left: auto;
font-size: 0.7rem;
font-weight: 500;
color: var(--text-muted, #94a3b8);
padding: 0.15rem 0.5rem;
border-radius: var(--radius, 6px);
border: 1px solid var(--border, #334155);
background: var(--bg-elevated, rgba(15, 23, 42, 0.6));
white-space: nowrap;
}

.map-unplotted--visible {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}

.panel__header--map {
display: flex;
align-items: center;
gap: 0.5rem;
}

.coverage-circle {
pointer-events: auto;
}

.coverage-circle--excellent { stroke: var(--accent-green); fill: var(--accent-green); }
.coverage-circle--good { stroke: var(--accent-cyan); fill: var(--accent-cyan); }
.coverage-circle--fair { stroke: var(--accent-amber); fill: var(--accent-amber); }
.coverage-circle--poor { stroke: var(--accent-red); fill: var(--accent-red); }
.coverage-circle--unknown { stroke: var(--text-muted); fill: var(--text-muted); }
6 changes: 5 additions & 1 deletion frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/dashboard.css" />
<link rel="stylesheet" href="css/map_coverage.css" />
<link rel="stylesheet" href="sidebar/sidebar.css" />
<link rel="stylesheet" href="topbar/topbar.css" />
<link rel="stylesheet" href="css/build_stamp.css" />
Expand Down Expand Up @@ -321,7 +322,10 @@
<div class="dashboard__main">
<section class="dashboard__map">
<div class="panel">
<div class="panel__header">Node Map</div>
<div class="panel__header panel__header--map">
<span>Node Map</span>
<span id="map-unplotted-count" class="map-unplotted" hidden></span>
</div>
<div id="map" class="panel__body map-container"></div>
</div>
</section>
Expand Down
5 changes: 5 additions & 0 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,11 @@ document.addEventListener('DOMContentLoaded', async () => {
});
}

nodeMap.setOnNodeClick(async (node) => {
if (backdrop) backdrop.classList.add('nd-backdrop--visible');
await nodeDrawer.open(node);
});

const origOpen = nodeDrawer.open.bind(nodeDrawer);
nodeDrawer.open = async (node) => {
if (backdrop) backdrop.classList.add('nd-backdrop--visible');
Expand Down
128 changes: 127 additions & 1 deletion frontend/js/components/node_map.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,12 @@ class NodeMap {

this._topologyLayer = L.layerGroup();
this._topologyVisible = false;
this._coverageLayer = L.layerGroup();
this._coverageVisible = false;
this._coverageNodes = {};
this._focusLine = null;
this._onNodeClick = null;
this._unplottedEl = document.getElementById('map-unplotted-count');

this._markerGroup = L.markerClusterGroup({
maxClusterRadius: 50,
Expand All @@ -68,19 +73,29 @@ class NodeMap {
});
this._map.addLayer(this._markerGroup);

const overlays = { 'Topology Links': this._topologyLayer };
const overlays = {
'Coverage View': this._coverageLayer,
'Topology Links': this._topologyLayer,
};
L.control.layers(null, overlays, { position: 'topright', collapsed: true }).addTo(this._map);

this._map.on('overlayadd', (e) => {
if (e.layer === this._topologyLayer) {
this._topologyVisible = true;
this._loadTopology();
}
if (e.layer === this._coverageLayer) {
this._coverageVisible = true;
this._loadCoverage();
}
});
this._map.on('overlayremove', (e) => {
if (e.layer === this._topologyLayer) {
this._topologyVisible = false;
}
if (e.layer === this._coverageLayer) {
this._coverageVisible = false;
}
});

this._initialized = true;
Expand Down Expand Up @@ -181,9 +196,29 @@ class NodeMap {
this._hasFitBounds = true;
}

this._refreshMapMeta();

if (this._topologyVisible) {
this._loadTopology();
}
if (this._coverageVisible) {
this._loadCoverage();
}
}

async _refreshMapMeta() {
try {
const res = await fetch('/api/nodes/coverage?limit=1');
if (!res.ok) return;
const data = await res.json();
this._updateUnplottedCount(data.unplotted_count);
} catch (_e) {
/* best-effort sidebar count */
}
}

setOnNodeClick(callback) {
this._onNodeClick = typeof callback === 'function' ? callback : null;
}

_addDeviceMarker(device) {
Expand Down Expand Up @@ -268,10 +303,18 @@ class NodeMap {
`Last heard: ${lastHeard}`
);

marker.on('click', () => this._handleNodeClick(n));

this._markerGroup.addLayer(marker);
this._markers[n.node_id] = marker;
}

_handleNodeClick(node) {
if (!this._onNodeClick) return;
const full = (this._lastNodes || []).find((row) => row.node_id === node.node_id) || node;
this._onNodeClick(full);
}

_formatRelativeTime(timestamp) {
if (!timestamp) return 'unknown';
const t = new Date(timestamp).getTime();
Expand Down Expand Up @@ -404,6 +447,89 @@ class NodeMap {
}, 200);
}

_coverageColor(quality) {
const root = getComputedStyle(document.documentElement);
const token = (name, fallback) => root.getPropertyValue(name).trim() || fallback;
const colors = {
excellent: token('--accent-green', '#00e5a0'),
good: token('--accent-cyan', '#06b6d4'),
fair: token('--accent-amber', '#f59e0b'),
poor: token('--accent-red', '#ef4444'),
unknown: token('--text-muted', '#64748b'),
};
return colors[quality] || colors.unknown;
}

_coverageRadiusMeters(packetCount) {
const min = 250;
const max = 4000;
const count = Math.max(1, Number(packetCount) || 1);
const normalized = Math.min(1, Math.log10(count + 1) / 3);
return min + normalized * (max - min);
}

_updateUnplottedCount(count) {
if (!this._unplottedEl) return;
const n = Number(count) || 0;
if (n <= 0) {
this._unplottedEl.hidden = true;
this._unplottedEl.textContent = '';
return;
}
this._unplottedEl.hidden = false;
this._unplottedEl.classList.add('map-unplotted--visible');
this._unplottedEl.textContent = `${n} unplotted`;
this._unplottedEl.title = `${n} node${n === 1 ? '' : 's'} without GPS`;
}

async _loadCoverage() {
try {
const res = await fetch('/api/nodes/coverage');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const plotted = Array.isArray(data.plotted) ? data.plotted : [];
this._coverageLayer.clearLayers();
this._coverageNodes = {};
this._updateUnplottedCount(data.unplotted_count);

for (const row of plotted) {
const lat = row.latitude;
const lon = row.longitude;
if (lat == null || lon == null) continue;

const quality = row.rssi_quality || 'unknown';
const color = this._coverageColor(quality);
const packets = row.recent_packet_count ?? row.packet_count ?? 1;
const radius = this._coverageRadiusMeters(packets);
const rssi = row.avg_rssi ?? row.latest_rssi;
const rssiLabel = rssi != null ? `${Number(rssi).toFixed(0)} dBm` : '--';

const circle = L.circle([lat, lon], {
radius,
color,
fillColor: color,
fillOpacity: 0.18,
weight: 1.5,
className: `coverage-circle coverage-circle--${quality}`,
});

const name = row.display_name || row.node_id || '--';
circle.bindPopup(
`<strong>${this._esc(name)}</strong><br>` +
`Avg RSSI: ${rssiLabel}<br>` +
`Packets (${data.window_hours || 168}h): ${packets}<br>` +
`Coverage radius: ~${Math.round(radius)} m`
);
circle.on('click', () => this._handleNodeClick(row));

this._coverageLayer.addLayer(circle);
this._coverageNodes[row.node_id] = row;
}
} catch (e) {
console.error('Coverage load failed:', e);
}
}

async _loadTopology() {
try {
const res = await fetch('/api/analytics/topology');
Expand Down
9 changes: 9 additions & 0 deletions src/api/routes/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,15 @@ async def network_summary():
return await _network_mapper.get_network_summary()


@router.get("/coverage")
async def coverage_data(
limit: int = Query(500, ge=1, le=2000),
hours: float = Query(168, ge=1, le=720),
):
"""RSSI coverage circles for nodes with GPS (JOIN nodes + packet aggregates)."""
return await _node_repo.get_coverage_data(limit=limit, hours=hours)


@router.get("/{node_id}/metrics_history")
async def metrics_history(
node_id: str,
Expand Down
87 changes: 84 additions & 3 deletions src/storage/node_repository.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
from __future__ import annotations

import logging
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Optional

from datetime import timedelta

from src.models.node import Node
from src.storage.database import DatabaseManager


def _rssi_quality(rssi: float | None) -> str:
if rssi is None:
return "unknown"
if rssi > -80:
return "excellent"
if rssi > -95:
return "good"
if rssi > -110:
return "fair"
return "poor"

logger = logging.getLogger(__name__)

# Rows created from corrupted RX (no decoded NodeInfo). Matches COMMON-ERRORS
Expand Down Expand Up @@ -153,6 +163,77 @@ def _enrich_row(row: dict) -> dict:
d["latest_hops"] = max(0, hop_start - hop_limit)
return d

async def get_coverage_data(
self, limit: int = 500, hours: float = 168,
) -> dict:
"""Nodes with GPS plus packet RSSI aggregates for the coverage map."""
since = (
datetime.now(timezone.utc) - timedelta(hours=hours)
).isoformat()
unplotted_row = await self._db.fetch_one(
"""
SELECT COUNT(*) AS cnt FROM nodes
WHERE latitude IS NULL OR longitude IS NULL
""",
)
total_row = await self._db.fetch_one("SELECT COUNT(*) AS cnt FROM nodes")
rows = await self._db.fetch_all(
"""
SELECT n.*,
p.rssi AS latest_rssi,
p.snr AS latest_snr,
agg.recent_packet_count,
agg.avg_rssi
FROM nodes n
LEFT JOIN (
SELECT source_id, rssi, snr,
ROW_NUMBER() OVER (
PARTITION BY source_id ORDER BY timestamp DESC
) AS rn
FROM packets
WHERE rssi IS NOT NULL
) p ON p.source_id = n.node_id AND p.rn = 1
LEFT JOIN (
SELECT source_id,
COUNT(*) AS recent_packet_count,
AVG(rssi) AS avg_rssi
FROM packets
WHERE rssi IS NOT NULL AND timestamp >= ?
GROUP BY source_id
) agg ON agg.source_id = n.node_id
WHERE n.latitude IS NOT NULL AND n.longitude IS NOT NULL
ORDER BY n.last_heard DESC
LIMIT ?
""",
(since, limit),
)
plotted = []
for row in rows:
node = self._row_to_node(row)
avg_rssi = row.get("avg_rssi")
latest_rssi = row.get("latest_rssi")
signal_rssi = avg_rssi if avg_rssi is not None else latest_rssi
plotted.append({
"node_id": node.node_id,
"display_name": node.display_name,
"latitude": node.latitude,
"longitude": node.longitude,
"protocol": node.protocol,
"packet_count": node.packet_count,
"recent_packet_count": int(row.get("recent_packet_count") or 0),
"avg_rssi": round(float(avg_rssi), 1) if avg_rssi is not None else None,
"latest_rssi": latest_rssi,
"rssi_quality": _rssi_quality(signal_rssi),
"last_heard": node.last_heard.isoformat(),
})
return {
"plotted": plotted,
"plotted_count": len(plotted),
"unplotted_count": int(unplotted_row["cnt"]) if unplotted_row else 0,
"total_nodes": int(total_row["cnt"]) if total_row else 0,
"window_hours": hours,
}

async def increment_packet_count(self, node_id: str) -> None:
await self._db.execute(
"UPDATE nodes SET packet_count = packet_count + 1, last_heard = ? WHERE node_id = ?",
Expand Down
Loading
Loading