Merge branch 'develop'

This commit is contained in:
pablorevilla-meshtastic committed 2026-10-23 14:23:31 -07:00
commit 9952426aee
28 files changed
+585 -147

No files matched your search

+3
View File
@@ -23,6 +23,9 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
### Version 3.0.6 — March 2026
- Daily snapshots: added the `daily_snapshot` table, scheduled snapshot capture, and `/api/snapshots/daily` for historical node, packet, and gateway counts.
Each new snapshot stores packets imported during the previous complete UTC day,
labeled with that day's date. Node and gateway counts are database totals at capture
time. Existing historical snapshots retain their previously stored packet totals.
- Stats/UI: expanded stats views with snapshot data and added channel filters to Chat and Firehose.
- Map/node fixes: corrected node location handling, map popup behavior, packet page display, and node list sorting.
- Ingestion/API reliability: improved duplicate `PacketSeen` handling, guarded invalid node info packets, and made traceroute/edge APIs more tolerant of decode failures.
+28
View File
@@ -227,9 +227,37 @@ Response Example
## 6. Config API
### GET `/api/snapshots/daily`
Returns stored daily snapshots. For new snapshots, `packet_count` is the number of
packet records imported during `date` (00:00 UTC inclusive to the next midnight
exclusive). The scheduled capture records the previous complete UTC day. Repeated
observations of the same packet are not counted separately. `node_count` counts distinct
packet senders whose packets were imported or observed during that UTC day (including
observations of previously imported packets). `gateway_count` counts distinct MQTT
gateways with packet observations imported during that day. These are activity counts,
not database totals; recipients alone do not count as active nodes. Activity uses local
import timestamps, not device timestamps. Older snapshots retain their originally stored
counts and are not automatically converted. Counts depend on the packet and observation
history still retained at capture time.
### GET `/api/config`
Returns a safe subset of server configuration.
`site.map_channel_colors` contains channel color overrides from the optional
`[map_channel_colors]` INI section. Each entry is `channel = #RRGGBB`; invalid colors
are omitted. Channel matching on the map ignores case, spaces, hyphens, and underscores.
Unlisted channels use generated colors. For example:
```ini
[map_channel_colors]
LongFast = #4363d8
LocalChat = #18aa18
```
Restart the web server and reload the map after changing these settings. Omitting
the section uses generated colors for every channel.
Response Example
```json
{
+1 -1
View File
@@ -3,7 +3,7 @@
import subprocess
from pathlib import Path
__version__ = "3.0.8"
__version__ = "3.0.9"
__release_date__ = "2026-5-12"
+2
View File
@@ -221,6 +221,8 @@
"air_channel": "Air & Channel Utilization",
"environment": "Environment Metrics",
"neighbors_chart": "Neighbors (Signal-to-Noise)",
"neighbors_chart_subtitle": "Last {days} days — {n} neighbors",
"neighbors_chart_subtitle_capped": "Last {days} days — the {n} neighbors with the most data points, of {total}",
"expand": "Expand",
"export_csv": "Export CSV",
"time": "Time",
+2
View File
@@ -207,6 +207,8 @@
"air_channel": "Utilización del aire y del canal",
"environment": "Métricas Ambientales",
"neighbors_chart": "Vecinos (Relación Señal/Ruido)",
"neighbors_chart_subtitle": "Últimos {days} días — {n} vecinos",
"neighbors_chart_subtitle_capped": "Últimos {days} días — los {n} vecinos con más datos, de {total}",
"expand": "Ampliar",
"export_csv": "Exportar CSV",
"time": "Hora",
+2
View File
@@ -202,6 +202,8 @@
"air_channel": "Использование сети и каналов",
"environment": "Метрики среды",
"neighbors_chart": "Соседи (Соотношение сигнал/шум)",
"neighbors_chart_subtitle": "Последние {days} дн. — {n} соседей",
"neighbors_chart_subtitle_capped": "Последние {days} дн. — {n} соседей с наибольшим числом точек, из {total}",
"expand": "Расширить",
"export_csv": "Экспорт в CSV",
"time": "Время",
+128 -29
View File
@@ -1,7 +1,7 @@
import logging
import re
import time
from datetime import UTC, datetime
from datetime import UTC, datetime, timedelta
from sqlalchemy import func, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
@@ -20,6 +20,39 @@ MQTT_GATEWAY_CACHE: set[int] = set()
UNKNOWN_NODE_NAME = "unknown name"
NODE_NAME_CONTROL_CHARS = re.compile(r"[\x00-\x1f\x7f]")
MQTT_DIRECT_NODE_ID = 1
# Meshtastic node ids are "!" + up to 8 hex digits; be a little lenient on length but the
# result must still fit the BigInteger columns.
NODE_ID_PATTERN = re.compile(r"^!([0-9a-fA-F]{1,16})$")
MAX_NODE_ID = 2**63 - 1
def parse_node_id(value: str | None) -> int | None:
"""Turn a "!deadbeef" style id into an int, or None when it is not one.
Gateway and user ids arrive over MQTT and are not trustworthy; a malformed one must
not raise, otherwise the whole ingest loop goes down with it.
"""
if not value:
return None
match = NODE_ID_PATTERN.match(value)
if not match:
return None
node_id = int(match.group(1), 16)
if node_id > MAX_NODE_ID:
return None
return node_id
def has_valid_location(lat: int | None, lon: int | None) -> bool:
return lat is not None and lon is not None and abs(lat) > 1 and abs(lon) > 1
def node_user_id(node_id: int) -> str:
return f"!{node_id:0{8}x}"
def placeholder_short_name(node_id: int) -> str:
return node_user_id(node_id)[-4:]
def normalize_node_name(name: str) -> str:
@@ -47,22 +80,76 @@ def storage_to_node_id(packet) -> int:
return packet.to
async def ensure_node_exists(session, node_id: int | None, channel: str | None) -> Node | None:
if node_id is None:
return None
node = (await session.execute(select(Node).where(Node.node_id == node_id))).scalar_one_or_none()
now_us = int(time.time() * 1_000_000)
if node:
node.last_seen_us = now_us
if node.first_seen_us is None:
node.first_seen_us = now_us
if channel and not node.channel:
node.channel = channel
return node
display_name = str(node_id)
node = Node(
id=node_user_id(node_id),
node_id=node_id,
long_name=display_name,
short_name=placeholder_short_name(node_id),
channel=channel,
first_seen_us=now_us,
last_seen_us=now_us,
)
session.add(node)
return node
async def capture_daily_snapshot() -> None:
today = datetime.now(UTC).date()
"""Record packet traffic and active nodes/gateways for the previous UTC day."""
day_end = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0)
day_start = day_end - timedelta(days=1)
start_us = int(day_start.timestamp() * 1_000_000)
end_us = int(day_end.timestamp() * 1_000_000)
snapshot_date = day_start.date()
async with mqtt_database.async_session() as session:
node_count = (await session.execute(select(func.count()).select_from(Node))).scalar_one()
# Include repeated observations of older packets as activity, too. Node.last_seen_us
# cannot answer this after a node has already been seen on the following day.
active_nodes = (
select(Packet.from_node_id)
.where(Packet.import_time_us >= start_us, Packet.import_time_us < end_us)
.union(
select(Packet.from_node_id)
.join(PacketSeen, PacketSeen.packet_id == Packet.id)
.where(PacketSeen.import_time_us >= start_us, PacketSeen.import_time_us < end_us)
)
.subquery()
)
node_count = (
await session.execute(select(func.count(active_nodes.c.from_node_id)))
).scalar_one()
packet_count = (
await session.execute(select(func.count()).select_from(Packet))
await session.execute(
select(func.count())
.select_from(Packet)
.where(Packet.import_time_us >= start_us, Packet.import_time_us < end_us)
)
).scalar_one()
gateway_count = (
await session.execute(
select(func.count()).select_from(Node).where(Node.is_mqtt_gateway.is_(True))
select(func.count(func.distinct(PacketSeen.node_id))).where(
PacketSeen.import_time_us >= start_us, PacketSeen.import_time_us < end_us
)
)
).scalar_one()
captured_at_us = int(time.time() * 1_000_000)
values = {
"snapshot_date": today,
"snapshot_date": snapshot_date,
"node_count": node_count,
"packet_count": packet_count,
"gateway_count": gateway_count,
@@ -89,7 +176,7 @@ async def capture_daily_snapshot() -> None:
else:
snapshot = (
await session.execute(
select(DailySnapshot).where(DailySnapshot.snapshot_date == today)
select(DailySnapshot).where(DailySnapshot.snapshot_date == snapshot_date)
)
).scalar_one_or_none()
if snapshot is None:
@@ -130,6 +217,7 @@ async def process_envelope(topic, env):
).scalar_one_or_none()
now_us = int(time.time() * 1_000_000)
has_location = has_valid_location(map_report.latitude_i, map_report.longitude_i)
if node:
node.node_id = node_id
@@ -139,8 +227,9 @@ async def process_envelope(topic, env):
node.role = role
if not node.channel:
node.channel = env.channel_id
node.last_lat = map_report.latitude_i
node.last_long = map_report.longitude_i
if has_location:
node.last_lat = map_report.latitude_i
node.last_long = map_report.longitude_i
node.firmware = map_report.firmware_version
node.last_seen_us = now_us
if node.first_seen_us is None:
@@ -155,11 +244,12 @@ async def process_envelope(topic, env):
role=role,
channel=env.channel_id,
firmware=map_report.firmware_version,
last_lat=map_report.latitude_i,
last_long=map_report.longitude_i,
first_seen_us=now_us,
last_seen_us=now_us,
)
if has_location:
node.last_lat = map_report.latitude_i
node.last_long = map_report.longitude_i
session.add(node)
except Exception as e:
print(f"Error processing MAP_REPORT_APP: {e}")
@@ -172,6 +262,8 @@ async def process_envelope(topic, env):
async with mqtt_database.async_session() as session:
# --- Packet insert with ON CONFLICT DO NOTHING
from_node_id = getattr(env.packet, "from", None)
result = await session.execute(select(Packet).where(Packet.id == packet_id))
packet = result.scalar_one_or_none()
if not packet:
@@ -179,7 +271,7 @@ async def process_envelope(topic, env):
packet_values = {
"id": packet_id,
"portnum": env.packet.decoded.portnum,
"from_node_id": getattr(env.packet, "from"),
"from_node_id": from_node_id,
"to_node_id": storage_to_node_id(env.packet),
"payload": env.packet.SerializeToString(),
"import_time_us": now_us,
@@ -213,23 +305,25 @@ async def process_envelope(topic, env):
# --- PacketSeen insert with conflict-safe handling
if not env.gateway_id:
print("WARNING: Missing gateway_id, skipping PacketSeen entry")
gateway_node_id = parse_node_id(env.gateway_id)
if gateway_node_id is None:
# Most likely a misconfiguration of a mqtt publisher?
logger.warning(
"Skipping PacketSeen entry: missing or malformed gateway_id %r", env.gateway_id
)
await session.commit()
return
else:
node_id = int(env.gateway_id[1:], 16)
if node_id not in MQTT_GATEWAY_CACHE:
MQTT_GATEWAY_CACHE.add(node_id)
if gateway_node_id not in MQTT_GATEWAY_CACHE:
MQTT_GATEWAY_CACHE.add(gateway_node_id)
await session.execute(
update(Node).where(Node.node_id == node_id).values(is_mqtt_gateway=True)
update(Node).where(Node.node_id == gateway_node_id).values(is_mqtt_gateway=True)
)
now_us = int(time.time() * 1_000_000)
seen_values = {
"packet_id": packet_id,
"node_id": int(env.gateway_id[1:], 16),
"node_id": gateway_node_id,
"channel": env.channel_id,
"rx_time": env.packet.rx_time,
"rx_snr": env.packet.rx_snr,
@@ -271,9 +365,8 @@ async def process_envelope(topic, env):
PortNum.NODEINFO_APP, env.packet.decoded.payload
)
if user and user.id:
if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]):
node_id = int(user.id[1:], 16)
else:
node_id = parse_node_id(user.id)
if node_id is None:
logger.warning(
"Skipping NODEINFO_APP packet %s: unable to determine node_id",
env.packet.id,
@@ -295,6 +388,13 @@ async def process_envelope(topic, env):
node = (
await session.execute(select(Node).where(Node.id == user.id))
).scalar_one_or_none()
if node is None:
# The same node may already be stored under a differently
# spelled id (e.g. "!00abcd" vs "!abcd"); node_id is unique, so
# update that row instead of inserting a duplicate.
node = (
await session.execute(select(Node).where(Node.node_id == node_id))
).scalar_one_or_none()
now_us = int(time.time() * 1_000_000)
long_name = normalize_node_name(user.long_name)
@@ -332,11 +432,10 @@ async def process_envelope(topic, env):
position = decode_payload.decode_payload(
PortNum.POSITION_APP, env.packet.decoded.payload
)
if position and position.latitude_i and position.longitude_i:
from_node_id = getattr(env.packet, "from")
node = (
await session.execute(select(Node).where(Node.node_id == from_node_id))
).scalar_one_or_none()
if position and has_valid_location(position.latitude_i, position.longitude_i):
node = await ensure_node_exists(
session, getattr(env.packet, "from", None), env.channel_id
)
if node:
now_us = int(time.time() * 1_000_000)
node.last_lat = position.latitude_i
@@ -356,7 +455,7 @@ async def process_envelope(topic, env):
packet_id=packet_id,
route=env.packet.decoded.payload,
done=not env.packet.decoded.want_response,
gateway_node_id=int(env.gateway_id[1:], 16),
gateway_node_id=gateway_node_id,
import_time_us=now_us,
)
)
+9 -4
View File
@@ -42,7 +42,12 @@ def destination_point(
return math.degrees(lat2), math.degrees(lon2)
@lru_cache(maxsize=512)
def _loop_is_safe(radius_km: float, step_km: float) -> bool:
"""A non-positive step or a non-finite radius would make the distance loop endless."""
return math.isfinite(radius_km) and math.isfinite(step_km) and step_km > 0
@lru_cache(maxsize=64)
def compute_coverage(
lat: float,
lon: float,
@@ -54,7 +59,7 @@ def compute_coverage(
step_km: float,
reliability: float,
) -> list[tuple[float, float, float]]:
if not ITM_AVAILABLE:
if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km):
return []
points = []
@@ -90,7 +95,7 @@ def compute_coverage(
return points
@lru_cache(maxsize=512)
@lru_cache(maxsize=64)
def compute_perimeter(
lat: float,
lon: float,
@@ -103,7 +108,7 @@ def compute_perimeter(
reliability: float,
threshold_dbm: float,
) -> list[tuple[float, float]]:
if not ITM_AVAILABLE:
if not ITM_AVAILABLE or not _loop_is_safe(radius_km, step_km):
return []
perimeter = []
+10
View File
@@ -0,0 +1,10 @@
/* Shared HTML escaping for strings that come from the mesh (node names, channel ids,
firmware strings, message text). Anything a radio can send must go through this
before it is placed in innerHTML, insertAdjacentHTML or a Leaflet popup/tooltip. */
(function () {
const MAP = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
window.escapeHtml = function escapeHtml(value) {
if (value === null || value === undefined) return "";
return String(value).replace(/[&<>"']/g, ch => MAP[ch]);
};
})();
+8 -7
View File
@@ -45,6 +45,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js" crossorigin></script>
<script src="/static/portmaps.js"></script>
<script src="/static/html-escape.js"></script>
<script>
(async function(){
@@ -115,12 +116,12 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
marker.originalColor = color;
markerById[node.node_id]=marker;
let popupContent=`<b>${node.long_name} (${node.short_name})</b><br>
<b>Channel:</b> ${node.channel}<br>
<b>Model:</b> ${node.hw_model}<br>
<b>Role:</b> ${node.role}<br>`;
let popupContent=`<b>${escapeHtml(node.long_name)} (${escapeHtml(node.short_name)})</b><br>
<b>Channel:</b> ${escapeHtml(node.channel)}<br>
<b>Model:</b> ${escapeHtml(node.hw_model)}<br>
<b>Role:</b> ${escapeHtml(node.role)}<br>`;
if(node.last_seen_us) popupContent+=`<b>Last seen:</b> ${timeAgoFromUs(node.last_seen_us)}<br>`;
if(node.firmware) popupContent+=`<b>Firmware:</b> ${node.firmware}<br>`;
if(node.firmware) popupContent+=`<b>Firmware:</b> ${escapeHtml(node.firmware)}<br>`;
marker.on('click', e=>{
e.originalEvent.stopPropagation();
@@ -139,7 +140,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
const color=hashToColor(channel);
const label=document.createElement('label');
label.style.color=color;
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${id}" checked> ${channel}`;
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${escapeHtml(id)}" checked> ${escapeHtml(channel)}`;
filterContainer.appendChild(label);
});
@@ -178,7 +179,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
let count=0;
const portName=portMap[portnum]||`Port ${portnum}`;
const tooltip=L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
.setContent(`${escapeHtml(longName)} (${escapeHtml(portName)})`).setLatLng(marker.getLatLng());
tooltip.addTo(map); marker.tooltip=tooltip;
const interval=setInterval(()=>{
if(map.hasLayer(marker)){
+11
View File
@@ -4,11 +4,14 @@ from datetime import datetime, timedelta, timezone
from sqlalchemy import Text, and_, cast, func, or_, select
from sqlalchemy.orm import lazyload
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, models
from meshview.models import Node, Packet, PacketSeen, Traceroute
logger = logging.getLogger(__name__)
BROADCAST_NODE_ID = 0xFFFFFFFF
async def get_node(node_id):
async with database.async_session() as session:
@@ -70,6 +73,14 @@ async def get_packets(
contains_lower = f"%{contains.lower()}%"
payload_text = cast(models.Packet.payload, Text)
conditions.append(func.lower(payload_text).like(contains_lower))
# Direct text messages are shown as "<redacted>" by the API, so they must not be
# searchable either: a match would reveal their contents one substring at a time.
conditions.append(
or_(
models.Packet.portnum != PortNum.TEXT_MESSAGE_APP,
models.Packet.to_node_id == BROADCAST_NODE_ID,
)
)
# Apply WHERE conditions
if conditions:
+3 -1
View File
@@ -10,6 +10,7 @@
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="/static/html-escape.js"></script>
{% block head %}{% endblock %}
@@ -39,8 +40,9 @@
height: 500px;
}
{% block css %}{% endblock %}
</style>
{% block css %}{% endblock %}
</head>
<body>
+2
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block css %}
<style>
.timestamp {
min-width: 10em;
color: #ccc;
@@ -53,6 +54,7 @@
.chat-filter {
min-width: 220px;
}
</style>
{% endblock %}
{% block body %}
+7 -9
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block css %}
<style>
.container {
margin: 0 auto;
padding: 10px;
@@ -90,6 +91,7 @@
.inline-link:hover {
color: #c7e6ff;
}
</style>
{% endblock %}
{% block body %}
@@ -199,9 +201,7 @@ function portLabel(portnum, payload, linksHtml) {
const name = PORT_MAP[portnum] || "Unknown";
const color = PORT_COLORS[portnum] || "#6c757d";
const safePayload = payload
? payload.replace(/"/g, "&quot;")
: "";
const safePayload = escapeHtml(payload || "");
return `
<span class="port-tag" style="background-color:${color}" title="${safePayload}">
@@ -325,7 +325,7 @@ async function fetchUpdates() {
${firehoseTranslations.all_broadcast || "All"}
</span>`
: `<a href="/node/${pkt.from_node_id}" style="text-decoration:underline; color:inherit;">
${nodeMap[pkt.from_node_id] || pkt.from_node_id}
${escapeHtml(nodeMap[pkt.from_node_id] || pkt.from_node_id)}
</a>`;
/* TO — includes translation */
@@ -339,7 +339,7 @@ async function fetchUpdates() {
${firehoseTranslations.all_broadcast || "All"}
</span>`
: `<a href="/node/${pkt.to_node_id}" style="text-decoration:underline; color:inherit;">
${nodeMap[pkt.to_node_id] || pkt.to_node_id}
${escapeHtml(nodeMap[pkt.to_node_id] || pkt.to_node_id)}
</a>`;
let inlineLinks = "";
@@ -369,9 +369,7 @@ async function fetchUpdates() {
>⮕</a>`;
}
const safePayload = (pkt.payload || "")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const safePayload = escapeHtml(pkt.payload || "");
const packetIdCell = pkt.packet_id
? `<a href="/packet/${pkt.storage_id || pkt.id}"
style="text-decoration:underline; color:inherit;">
@@ -395,7 +393,7 @@ async function fetchUpdates() {
<td>${from}</td>
<td>${to}</td>
<td>${pkt.channel || "—"}</td>
<td>${escapeHtml(pkt.channel || "—")}</td>
<td>${portLabel(pkt.portnum, pkt.payload, inlineLinks)}</td>
</tr>
+165 -28
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block css %}
<style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
@@ -27,9 +28,9 @@
background: white;
color: black;
border: 1px solid #111;
border-radius: 6px;
padding: 6px 10px;
font-size: 14px;
border-radius: 4px;
padding: 3px 6px;
font-size: 12px;
font-weight: 400;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
}
@@ -37,6 +38,33 @@
animation: textPulse 1.1s ease-in-out 6;
border-color: #ff8c00;
}
.packet-seen-trace {
stroke-dasharray: 0.01px var(--packet-trace-gap, 999px);
stroke-linecap: round;
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.65));
animation: packetTrace var(--packet-trace-duration, 6.5s) linear 1 forwards;
}
.packet-seen-landing {
transform-box: fill-box;
transform-origin: center;
animation: packetSeenLanding 1s ease-out 1 forwards;
}
@keyframes packetTrace {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: calc(-1 * var(--packet-trace-distance, 1000px)); }
}
@keyframes packetSeenLanding {
from {
stroke-opacity: 0.7;
fill-opacity: 0.24;
transform: scale(0.3);
}
to {
stroke-opacity: 0;
fill-opacity: 0;
transform: scale(1.6);
}
}
@keyframes textPulse {
0% { box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
50% { box-shadow: 0 4px 14px rgba(255,140,0,0.45); }
@@ -90,6 +118,7 @@
.unmapped-node { font-weight: 400; color: #000; }
.unmapped-empty { color: #666; font-style: italic; }
</style>
</style>
{% endblock %}
{% block body %}
@@ -207,6 +236,7 @@ L.control.scale({
}).addTo(map);
var nodeLayer = L.layerGroup().addTo(map);
var edgeLayer = L.layerGroup().addTo(map);
var packetSeenLayer = L.layerGroup();
var trafficHeatLayer = L.heatLayer([], {
radius: 34,
blur: 18,
@@ -221,6 +251,7 @@ var trafficHeatLayer = L.heatLayer([], {
});
var overlayLayers = {
"Nodes": nodeLayer,
"Packets Reach": packetSeenLayer,
"Traffic HeatMap": trafficHeatLayer
};
L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map);
@@ -229,32 +260,26 @@ L.control.layers(baseLayers, overlayLayers, { collapsed: false }).addTo(map);
var nodes = [], markers = {}, markerById = {}, nodeMap = new Map();
var selectedNodeId = null;
var activeBlinks = new Map(), lastImportTime = null;
var activePacketSeenHighlights = [];
var mapInterval = 0;
var unmappedPackets = [];
var trafficHeatLoaded = false;
var trafficHeatLoading = false;
const UNMAPPED_LIMIT = 50;
const UNMAPPED_TTL_MS = 5000;
const PACKET_SEEN_DELAY_MS = 15000;
const PACKET_TRACE_MIN_DURATION_MS = 1500;
const PACKET_TRACE_MAX_DURATION_MS = 6500;
const PACKET_TRACE_MS_PER_PIXEL = 18;
const PACKET_LANDING_DURATION_MS = 1000;
const portMap = window.PORT_LABEL_MAP;
// Fallback colors for channels that are not Meshtastic presets.
// Every entry clears 3:1 contrast against both the light map tiles and the dark
// filter panel, and stays perceptually far from every channelColorOverrides value.
// Generated colors for channels without a configured override.
const palette = ["#18aa18","#976049","#a5508c","#1898dc","#3a8846","#dc1884",
"#b8813d","#b64f35","#408b18","#4c719e","#be379c","#18aa52"];
const channelColorOverrides = {
longfast: "#4363d8",
longslow: "#000075",
verylongslow: "#808000",
mediumslow: "#008080",
mediumfast: "#e6194b",
shortslow: "#911eb4",
shortfast: "#f58231",
longmoderate: "#46f0f0",
shortturbo: "#f032e6"
};
const channelColorOverrides = new Map();
const colorMap = new Map();
const channelSet = new Set();
@@ -279,7 +304,7 @@ function hashToColor(str){
const channel = String(str || "Unknown");
if(colorMap.has(channel)) return colorMap.get(channel);
const override = channelColorOverrides[normalizeChannelName(channel)];
const override = channelColorOverrides.get(normalizeChannelName(channel));
const c = override || palette[Math.floor(hashToUnit(channel) * palette.length) % palette.length];
colorMap.set(channel,c);
return c;
@@ -395,6 +420,7 @@ function fetchNewPackets(){
} else {
addUnmappedPacket(pkt, nodeData);
}
schedulePacketSeenNodes(pkt);
});
lastImportTime = latest;
@@ -430,6 +456,7 @@ map.on("overlayadd", e => {
map.on("overlayremove", e => {
if(e.layer === nodeLayer) clearActiveBlinks();
if(e.layer === packetSeenLayer) clearPacketSeenHighlights();
});
async function waitForConfig() {
@@ -438,6 +465,9 @@ async function waitForConfig() {
}
try {
const cfg = await window._siteConfigPromise;
for(const [channel, color] of Object.entries(cfg.site?.map_channel_colors || {})){
channelColorOverrides.set(normalizeChannelName(channel), color);
}
return cfg.site || {};
} catch (err) {
console.error("Error loading site config:", err);
@@ -486,7 +516,8 @@ initMapPolling();
fetch('/api/nodes?days_active=3')
.then(r=>r.json())
.then(data=>{
.then(async data=>{
await waitForConfig();
if(!data.nodes) return;
nodes = data.nodes.map(n=>({
@@ -753,8 +784,9 @@ function isNodeVisibleForPackets(marker){
function clearBlinkForMarker(marker){
if(activeBlinks.has(marker)){
const interval = activeBlinks.get(marker);
clearInterval(interval);
const blink = activeBlinks.get(marker);
clearInterval(blink.interval);
if(blink.tooltipTimeout) clearTimeout(blink.tooltipTimeout);
activeBlinks.delete(marker);
}
marker.setStyle({ fillColor: marker.originalColor });
@@ -769,6 +801,96 @@ function clearActiveBlinks(){
activeBlinks.clear();
}
function clearPacketSeenHighlights(){
activePacketSeenHighlights.forEach(timeout => clearTimeout(timeout));
activePacketSeenHighlights = [];
packetSeenLayer.clearLayers();
}
function schedulePacketSeenNodes(pkt){
if(!map.hasLayer(packetSeenLayer) || pkt.id == null) return;
const sourceNode = nodeMap.get(pkt.from_node_id);
if(!sourceNode || isInvalidCoord(sourceNode)) return;
const timeout = setTimeout(() => showPacketSeenNodes(pkt), PACKET_SEEN_DELAY_MS);
activePacketSeenHighlights.push(timeout);
}
async function showPacketSeenNodes(pkt){
if(!map.hasLayer(packetSeenLayer) || pkt.id == null) return;
try {
const res = await fetch(`/api/packets_seen/${pkt.id}`);
if(!res.ok) return;
const data = await res.json();
const seen = data.seen || [];
const sourceId = pkt.from_node_id;
const sourceNode = nodeMap.get(sourceId);
const sourceLatLng = sourceNode && !isInvalidCoord(sourceNode)
? getNodeLatLng(sourceNode)
: null;
seen.forEach(row => {
const node = nodeMap.get(row.node_id);
if(!node || isInvalidCoord(node)) return;
const isSource = row.node_id === sourceId;
if(!sourceLatLng || isSource) return;
const markerLatLng = markerById[row.node_id]?.getLatLng();
const lat = markerLatLng?.lat ?? node.lat;
const lng = markerLatLng?.lng ?? node.long;
const trace = L.polyline([[sourceLatLng.lat, sourceLatLng.lng], [lat, lng]], {
color: "#000",
weight: 3,
opacity: 0.9,
className: "packet-seen-trace",
interactive: false
}).addTo(packetSeenLayer);
const traceEl = trace.getElement();
let traceDurationMs = PACKET_TRACE_MAX_DURATION_MS;
if(traceEl){
const traceLength = traceEl.getTotalLength();
traceDurationMs = Math.min(
PACKET_TRACE_MAX_DURATION_MS,
Math.max(PACKET_TRACE_MIN_DURATION_MS, traceLength * PACKET_TRACE_MS_PER_PIXEL)
);
traceEl.style.setProperty("--packet-trace-gap", `${traceLength + 20}px`);
traceEl.style.setProperty("--packet-trace-distance", `${traceLength + 20}px`);
traceEl.style.setProperty("--packet-trace-duration", `${traceDurationMs}ms`);
}
const landingTimeout = setTimeout(() => {
const landing = L.circleMarker([lat, lng], {
radius: 16,
color: "#000",
fillColor: "#000",
fillOpacity: 0.24,
opacity: 0.7,
weight: 2,
className: "packet-seen-landing",
interactive: false
}).addTo(packetSeenLayer);
const clearLandingTimeout = setTimeout(() => {
packetSeenLayer.removeLayer(landing);
}, PACKET_LANDING_DURATION_MS);
activePacketSeenHighlights.push(clearLandingTimeout);
}, traceDurationMs);
activePacketSeenHighlights.push(landingTimeout);
const timeout = setTimeout(() => {
packetSeenLayer.removeLayer(trace);
}, traceDurationMs + PACKET_LANDING_DURATION_MS);
activePacketSeenHighlights.push(timeout);
});
} catch(err) {
console.error("Failed to load packet seen nodes", pkt.id, err);
}
}
function blinkNode(marker,longName,portnum,payload){
if(!isNodeVisibleForPackets(marker)) return;
@@ -776,9 +898,11 @@ function blinkNode(marker,longName,portnum,payload){
clearBlinkForMarker(marker);
}
let blinkCount = 0;
const blinkStart = Date.now();
const blinkDurationMs = 7000;
const tooltipDurationMs = 5000;
const blinkDurationMs = map.hasLayer(packetSeenLayer) ? 15000 : tooltipDurationMs;
const fastBlinkStartMs = blinkDurationMs - 3000;
const blinkColor = "yellow";
const safeName = escapeHtml(longName);
const portLabel = portMap[portnum] || `Port ${portnum ?? "?"}`;
const payloadText = (payload || "").trim();
@@ -799,26 +923,39 @@ function blinkNode(marker,longName,portnum,payload){
.addTo(map);
marker.tooltip = tooltip;
const tooltipTimeout = setTimeout(() => {
if(blinkDurationMs === tooltipDurationMs){
clearBlinkForMarker(marker);
return;
}
if(marker.tooltip){
map.removeLayer(marker.tooltip);
marker.tooltip = null;
}
}, tooltipDurationMs);
const interval = setInterval(()=>{
const elapsedMs = Date.now() - blinkStart;
const blinkPeriodMs = elapsedMs >= fastBlinkStartMs ? 250 : 500;
if(isNodeVisibleForPackets(marker)){
marker.setStyle({
fillColor: blinkCount%2===0 ? 'yellow' : marker.originalColor
fillColor: Math.floor(elapsedMs / blinkPeriodMs)%2===0
? blinkColor
: marker.originalColor
});
marker.bringToFront();
} else {
clearBlinkForMarker(marker);
return;
}
blinkCount++;
if(Date.now() - blinkStart > blinkDurationMs){
if(elapsedMs > blinkDurationMs){
clearBlinkForMarker(marker);
}
},500);
},250);
activeBlinks.set(marker, interval);
activeBlinks.set(marker, { interval, tooltipTimeout });
}
/* ======================================================
+2
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block css %}
<style>
.timestamp { min-width: 10em; color: #ccc; }
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
@@ -26,6 +27,7 @@
#weekly-message { margin: 15px 0; font-weight: bold; color: #ffeb3b; }
#total-count { margin-bottom: 10px; font-style: italic; color: #ccc; }
#net-note { margin: 16px 0 0; font-size: 0.9rem; color: #aaa; }
</style>
{% endblock %}
{% block body %}
+98 -38
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block css %}
<style>
{{ super() }}
/* --- Map --- */
@@ -295,7 +296,7 @@
border-color:#48bb78;
color:#fff;
}
</style>
{% endblock %}
{% block body %}
@@ -548,9 +549,9 @@ function makeNodePopup(node) {
return `
<div style="font-size:0.9em">
<a href="/node/${node.node_id}" style="color:inherit; text-decoration:underline;">
<b>${node.long_name || node.short_name || node.node_id}</b>
<b>${escapeHtml(node.long_name || node.short_name || node.node_id)}</b>
</a>
${node.short_name ? ` (${node.short_name})` : ""}<br>
${node.short_name ? ` (${escapeHtml(node.short_name)})` : ""}<br>
<b><span data-translate-lang="node_id">
${nodeTranslations.node_id || "Node ID"}:
@@ -558,19 +559,19 @@ function makeNodePopup(node) {
<b><span data-translate-lang="hw_model">
${nodeTranslations.hw_model || "HW Model"}:
</span></b> ${node.hw_model ?? "—"}<br>
</span></b> ${escapeHtml(node.hw_model ?? "—")}<br>
<b><span data-translate-lang="channel">
${nodeTranslations.channel || "Channel"}:
</span></b> ${node.channel ?? "—"}<br>
</span></b> ${escapeHtml(node.channel ?? "—")}<br>
<b><span data-translate-lang="role">
${nodeTranslations.role || "Role"}:
</span></b> ${node.role ?? "—"}<br>
</span></b> ${escapeHtml(node.role ?? "—")}<br>
<b><span data-translate-lang="firmware">
${nodeTranslations.firmware || "Firmware"}:
</span></b> ${node.firmware ?? "—"}<br>
</span></b> ${escapeHtml(node.firmware ?? "—")}<br>
<b><span data-translate-lang="last_update">
${nodeTranslations.last_update || "Last Update"}:
@@ -745,7 +746,7 @@ function nodeLink(id, labelOverride = null) {
const label = labelOverride || nodeMap[id] || id;
return `<a href="/node/${id}" style="text-decoration:underline; color:inherit;">
${label}
${escapeHtml(label)}
</a>`;
}
@@ -855,7 +856,7 @@ function addMarker(id, lat, lon, color = "red", node = null) {
node = nodeCache[id] || null;
}
const popupHtml = node ? makeNodePopup(node) : `<b>${id}</b>`;
const popupHtml = node ? makeNodePopup(node) : `<b>${escapeHtml(id)}</b>`;
const m = L.circleMarker([lat, lon], {
radius: 6,
@@ -1046,8 +1047,7 @@ async function loadPackets(filters = {}) {
// ================================
// TABLE ROW
// ================================
const safePayload = (pkt.payload || "")
.replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;"));
const safePayload = escapeHtml(pkt.payload || "");
const localTime = formatLocalTime(pkt.import_time_us);
const fromCell = nodeLink(pkt.from_node_id, pkt.long_name);
@@ -1294,13 +1294,26 @@ async function loadLatestNeighborIds() {
/* ======================================================
NEIGHBOR CHART (portnum=71)
The request is bounded to NEIGHBOR_WINDOW_DAYS through the
API's `since` parameter, and only the NEIGHBOR_MAX_SERIES
neighbors with the most data points in that window are
drawn. Without both limits a busy node produces hundreds of
legend entries covering weeks of history.
====================================================== */
const NEIGHBOR_WINDOW_DAYS = 7;
const NEIGHBOR_MAX_SERIES = 15;
async function loadNeighborTimeSeries() {
const container = document.getElementById("neighbor_chart_container");
const chartEl = document.getElementById("chart_neighbors");
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=500`;
// Start of the window, in microseconds (import_time_us is epoch-us).
const sinceUs = (Date.now() - NEIGHBOR_WINDOW_DAYS * 86400 * 1000) * 1000;
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}`
+ `&since=${sinceUs}&limit=500`;
const res = await fetch(url);
if (!res.ok) {
@@ -1309,7 +1322,11 @@ async function loadNeighborTimeSeries() {
}
const data = await res.json();
const packets = data.packets || [];
// Re-apply the window here too, so the chart cannot drift past
// it if the packet source ever returns something older.
const packets = (data.packets || [])
.filter(p => (p.import_time_us || 0) >= sinceUs);
if (!packets.length) {
container.style.display = "none";
@@ -1319,7 +1336,8 @@ async function loadNeighborTimeSeries() {
// Sort packets chronologically (microseconds)
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
const neighborHistory = {}; // node_id -> { name, times[], snr[] }
// --- Pass 1: collect the readings, without looking up names yet ---
const neighborHistory = {}; // node_id -> { times[], snr[] }
for (const pkt of packets) {
if (!pkt.import_time_us || !pkt.payload) continue;
@@ -1338,39 +1356,56 @@ async function loadNeighborTimeSeries() {
if (!idMatch || !snrMatch) continue;
const nid = parseInt(idMatch[1], 10);
const snr = parseFloat(snrMatch[1]);
// Fetch neighbor metadata once
const neighbor = await fetchNodeFromApi(nid);
if (!neighborHistory[nid]) {
neighborHistory[nid] = {
name: neighbor?.short_name ||
neighbor?.long_name ||
`Node ${nid}`,
times: [],
snr: []
};
neighborHistory[nid] = { times: [], snr: [] };
}
neighborHistory[nid].times.push(ts);
neighborHistory[nid].snr.push(snr);
neighborHistory[nid].snr.push(parseFloat(snrMatch[1]));
}
}
// Collect ALL timestamps across neighbors
const allTimes = new Set();
Object.values(neighborHistory).forEach(entry => {
entry.times.forEach(t => allTimes.add(t));
});
// --- Keep the neighbors with the most data points in the window ---
const ranked = Object.entries(neighborHistory)
.sort((a, b) => b[1].times.length - a[1].times.length);
// Sort timestamps numerically
const xTimes = Array.from(allTimes).sort((a, b) => a - b);
const kept = ranked.slice(0, NEIGHBOR_MAX_SERIES);
const dropped = ranked.length - kept.length;
if (!kept.length) {
container.style.display = "none";
return;
}
// --- Pass 2: names, only for the series that are drawn ---
await Promise.all(kept.map(async ([nid, entry]) => {
const neighbor = await fetchNodeFromApi(nid);
entry.name = neighbor?.short_name ||
neighbor?.long_name ||
`Node ${nid}`;
}));
// ECharts identifies a series by its name, so two neighbors sharing
// a short name would be drawn as one line. Keep them apart.
const usedNames = new Set();
for (const [nid, entry] of kept) {
if (usedNames.has(entry.name)) entry.name = `${entry.name} (${nid})`;
usedNames.add(entry.name);
}
// Timestamps across the series that are drawn
const xTimes = Array.from(
new Set(kept.flatMap(([, entry]) => entry.times))
).sort((a, b) => a - b);
const legend = [];
const series = [];
for (const entry of Object.values(neighborHistory)) {
for (const [, entry] of kept) {
const byTime = new Map();
entry.times.forEach((t, i) => byTime.set(t, entry.snr[i]));
legend.push(entry.name);
series.push({
@@ -1379,21 +1414,46 @@ async function loadNeighborTimeSeries() {
smooth: true,
connectNulls: true,
showSymbol: false,
data: xTimes.map(t => {
const idx = entry.times.indexOf(t);
return idx >= 0 ? entry.snr[idx] : null;
})
data: xTimes.map(t => (byTime.has(t) ? byTime.get(t) : null))
});
}
// Values behind the "Export CSV" button: the most recent SNR
// in the window, per neighbor.
neighborData = {
ids: kept.map(([nid]) => nid),
names: kept.map(([, entry]) => entry.name),
snrs: kept.map(([, entry]) => entry.snr[entry.snr.length - 1])
};
// Say which neighbors are on screen, so a missing line is not a mystery.
const subtextTemplate = dropped > 0
? (nodeTranslations.neighbors_chart_subtitle_capped ||
"Last {days} days — the {n} neighbors with the most data points, of {total}")
: (nodeTranslations.neighbors_chart_subtitle ||
"Last {days} days — {n} neighbors");
const subtext = subtextTemplate
.replace("{days}", NEIGHBOR_WINDOW_DAYS)
.replace("{n}", kept.length)
.replace("{total}", ranked.length);
const chart = echarts.init(chartEl);
chart.setOption({
title: {
subtext,
left: "center",
bottom: 0,
subtextStyle: { color: "#888", fontSize: 11 }
},
grid: { bottom: 60 },
tooltip: {
trigger: "axis",
axisPointer: { type: "line" }
},
legend: {
type: "scroll",
data: legend,
textStyle: { color: "#ccc" }
},
+2
View File
@@ -5,6 +5,7 @@
{% endblock %}
{% block css %}
<style>
#mynetwork {
width: 100%;
height: 100vh;
@@ -82,6 +83,7 @@
border-radius: 3px;
}
.circle { border-radius: 6px; }
</style>
{% endblock %}
{% block body %}
+12 -12
View File
@@ -652,14 +652,14 @@ document.addEventListener("DOMContentLoaded", async function() {
// DESKTOP TABLE ROW
const row = document.createElement("tr");
row.innerHTML = `
<td>${node.short_name || "N/A"}</td>
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
<td>${node.hw_model || "N/A"}</td>
<td>${node.firmware || "N/A"}</td>
<td>${node.role || "N/A"}</td>
<td>${escapeHtml(node.short_name || "N/A")}</td>
<td><a href="/node/${node.node_id}">${escapeHtml(node.long_name || "N/A")}</a></td>
<td>${escapeHtml(node.hw_model || "N/A")}</td>
<td>${escapeHtml(node.firmware || "N/A")}</td>
<td>${escapeHtml(node.role || "N/A")}</td>
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.channel || "N/A"}</td>
<td>${escapeHtml(node.channel || "N/A")}</td>
<td>${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")}</td>
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
<td style="text-align:center;">
@@ -675,22 +675,22 @@ document.addEventListener("DOMContentLoaded", async function() {
card.className = "node-card";
card.innerHTML = `
<div class="node-card-header">
<span>${node.short_name || node.long_name || node.node_id}</span>
<span>${escapeHtml(node.short_name || node.long_name || node.node_id)}</span>
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
${star}
</span>
</div>
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
<div class="node-card-field"><b>Name:</b> ${escapeHtml(node.long_name || "N/A")}</div>
<div class="node-card-field"><b>HW:</b> ${escapeHtml(node.hw_model || "N/A")}</div>
<div class="node-card-field"><b>Firmware:</b> ${escapeHtml(node.firmware || "N/A")}</div>
<div class="node-card-field"><b>Role:</b> ${escapeHtml(node.role || "N/A")}</div>
<div class="node-card-field"><b>Location:</b>
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
</div>
<div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div>
<div class="node-card-field"><b>Channel:</b> ${escapeHtml(node.channel || "N/A")}</div>
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
+9 -9
View File
@@ -206,12 +206,12 @@ document.addEventListener("DOMContentLoaded", async () => {
const fromNodeObj = nodeLookup[p.from_node_id];
const toNodeObj = nodeLookup[p.to_node_id];
const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id;
const fromNodeLabel = escapeHtml(fromNodeObj?.long_name || p.from_node_id);
const toNodeLabel =
p.to_node_id == 4294967295
? (packetTranslations.all_broadcast || "All")
: (toNodeObj?.long_name || p.to_node_id);
: escapeHtml(toNodeObj?.long_name || p.to_node_id);
/* ---------------------------------------------
Parse payload for lat/lon
@@ -267,7 +267,7 @@ document.addEventListener("DOMContentLoaded", async () => {
}</dd>
<dt data-translate-lang="channel">${packetTranslations.channel || "Channel"}:</dt>
<dd>${p.channel ?? "—"}</dd>
<dd>${escapeHtml(p.channel ?? "—")}</dd>
<dt data-translate-lang="port">${packetTranslations.port || "Port"}:</dt>
<dd><i>${PORT_NAMES[p.portnum] || "UNKNOWN APP"}</i> (${p.portnum})</dd>
@@ -338,7 +338,7 @@ document.addEventListener("DOMContentLoaded", async () => {
Lat: ${srcLat.toFixed(6)}<br>
Lon: ${srcLon.toFixed(6)}<br>
<span data-translate-lang="from_node">${packetTranslations.from_node || "From Node:"}</span> ${fromNodeLabel}<br>
<span data-translate-lang="channel">${packetTranslations.channel || "Channel:"}</span> ${p.channel ?? "—"}<br>
<span data-translate-lang="channel">${packetTranslations.channel || "Channel:"}</span> ${escapeHtml(p.channel ?? "—")}<br>
<span data-translate-lang="port">${packetTranslations.port || "Port:"}</span> ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
</div>
`);
@@ -427,7 +427,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
const rows = hopGroups[hopKey].map(s => {
const node = nodeLookup[s.node_id];
const label = node?.long_name || s.node_id;
const label = escapeHtml(node?.long_name || s.node_id);
const hopDisplay = formatHopDisplay(hopKey, s.hop_start);
const timeStr = s.import_time_us
@@ -473,8 +473,8 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
<b>${label}</b><br>
<span data-translate-lang="node_id_short">Node ID</span>:
<a href="/node/${s.node_id}">${s.node_id}</a><br>
HW: ${node?.hw_model ?? "—"}<br>
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
HW: ${escapeHtml(node?.hw_model ?? "—")}<br>
<span data-translate-lang="channel">Channel</span>: ${escapeHtml(s.channel ?? "—")}<br>
${
distanceKm !== null
? `<span data-translate-lang="distance">Distance</span>:
@@ -500,7 +500,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
<td>${s.rx_rssi ?? "—"}</td>
<td>${s.rx_snr ?? "—"}</td>
<td>${hopDisplay}</td>
<td>${s.channel ?? "—"}</td>
<td>${escapeHtml(s.channel ?? "—")}</td>
<td>${timeStr}</td>
</tr>
`;
@@ -535,7 +535,7 @@ seenTableBody.innerHTML = Object.keys(hopGroups)
Escape HTML helper
----------------------------------------------*/
function escapeHtml(unsafe) {
return (unsafe ?? "").replace(/[&<"'>]/g, m => ({
return String(unsafe ?? "").replace(/[&<"'>]/g, m => ({
"&":"&amp;",
"<":"&lt;",
">":"&gt;",
+2
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block css %}
<style>
.reach-page {
max-width: 1200px;
margin: 0 auto;
@@ -258,6 +259,7 @@
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block body %}
+2
View File
@@ -1,6 +1,7 @@
{% extends "base.html" %}
{% block css %}
<style>
#packet_details {
height: 95vh;
overflow: auto;
@@ -94,6 +95,7 @@
border: none;
border-radius: 4px;
}
</style>
{% endblock %}
{% block head %}
+3 -3
View File
@@ -194,11 +194,11 @@ async function renderTable() {
<td>
<a class="node-link" href="/node/${n.node_id}"
onclick="event.stopPropagation()">
${n.long_name || n.node_id}
${escapeHtml(n.long_name || n.node_id)}
</a>
</td>
<td>${n.short_name || ""}</td>
<td>${n.channel || ""}</td>
<td>${escapeHtml(n.short_name || "")}</td>
<td>${escapeHtml(n.channel || "")}</td>
<td>${n.sent}</td>
<td>${n.seen}</td>
<td><span class="${avgClass(n.avg)}">${n.avg.toFixed(1)}</span></td>
+2
View File
@@ -5,6 +5,7 @@
{% endblock %}
{% block css %}
<style>
#traceroute-graph {
width: 100%;
height: calc(100vh - 270px);
@@ -62,6 +63,7 @@
#traceroute-error {
color: #ff6b6b;
}
</style>
{% endblock %}
{% block body %}
+5 -2
View File
@@ -241,8 +241,11 @@ async def serve_page(request):
if not page.endswith(".html"):
page = f"{page}.html"
html_file = pathlib.Path(__file__).parent / "static" / page
if not html_file.exists():
# aiohttp lets an encoded slash (%2F) through a single path segment, so resolve the
# final path and make sure it is still inside the static directory.
static_root = (pathlib.Path(__file__).parent / "static").resolve()
html_file = (static_root / page).resolve()
if static_root not in html_file.parents or not html_file.is_file():
raise web.HTTPNotFound(text=f"Page '{page}' not found")
content = html_file.read_text(encoding="utf-8")
+48 -3
View File
@@ -1,10 +1,12 @@
"""API endpoints for MeshView."""
import asyncio
import datetime
import json
import logging
import math
import os
import re
from aiohttp import web
from sqlalchemy import func, select
@@ -37,6 +39,9 @@ _LANG_CACHE = {}
# Create dedicated route table for API endpoints
routes = web.RouteTableDef()
# Language codes are short identifiers such as "en", "es", "pt-BR".
LANG_CODE_RE = re.compile(r"^[A-Za-z]{2,3}(?:[-_][A-Za-z0-9]{2,8})?$")
def _config_bool(section: str, key: str, default: bool = False) -> bool:
return str(CONFIG.get(section, {}).get(key, default)).lower() in ("1", "true", "yes", "on")
@@ -150,6 +155,20 @@ def _bearing_deg(lat1, lon1, lat2, lon2):
OBSERVED_MAX_DISTANCE_KM = 50.0
# Bounds for /api/coverage query parameters. The ITM loop runs once per (distance step x
# bearing), so the request size must be capped or a single call can hold the CPU for minutes.
COVERAGE_LIMITS = {
"freq_mhz": (1.0, 10_000.0),
"tx_dbm": (-50.0, 60.0),
"tx_height_m": (0.0, 1_000.0),
"rx_height_m": (0.0, 1_000.0),
"radius_km": (1.0, 200.0),
"step_km": (0.05, 50.0),
"reliability": (0.01, 0.99),
"threshold_dbm": (-200.0, 0.0),
}
COVERAGE_MAX_STEPS = 4_000 # radius_km / step_km
def init_api_module(packet_class, seq_regex, lang_dir):
"""Initialize API module with dependencies from main web module."""
@@ -725,6 +744,12 @@ async def api_config(request):
"map_bottom_right_lat": get_float(site, "map_bottom_right_lat", 36.0),
"map_bottom_right_lon": get_float(site, "map_bottom_right_lon", -121.0),
"map_interval": get_int(site, "map_interval", 3),
"map_channel_colors": {
channel: color.strip()
for channel, color in CONFIG.get("map_channel_colors", {}).items()
if re.fullmatch(r"#[0-9a-fA-F]{6}", color.strip())
},
"firehose_interval": get_int(site, "firehose_interval", 3),
"weekly_net_message": get_str(
site, "weekly_net_message", "Weekly Mesh check-in message."
@@ -779,6 +804,10 @@ async def api_lang(request):
lang_code = request.query.get("lang") or CONFIG.get("site", {}).get("language", "en")
section = request.query.get("section")
# The code names a file, so it must stay a plain language code. Anything else (a path,
# an absolute filename) falls back to English just like an unknown language does.
if not LANG_CODE_RE.match(lang_code):
lang_code = "en"
lang_file = os.path.join(LANG_DIR, f"{lang_code}.json")
if not os.path.exists(lang_file):
lang_file = os.path.join(LANG_DIR, "en.json")
@@ -1219,12 +1248,19 @@ async def api_coverage(request):
if value is None:
return default
try:
return float(value)
parsed = float(value)
except ValueError as exc:
raise web.HTTPBadRequest(
text=json.dumps({"error": f"{name} must be a number"}),
content_type="application/json",
) from exc
low, high = COVERAGE_LIMITS[name]
if not math.isfinite(parsed) or not (low <= parsed <= high):
raise web.HTTPBadRequest(
text=json.dumps({"error": f"{name} must be between {low} and {high}"}),
content_type="application/json",
)
return parsed
try:
freq_mhz = parse_float("freq_mhz", 907.0)
@@ -1238,6 +1274,11 @@ async def api_coverage(request):
except web.HTTPBadRequest as exc:
raise exc
if radius_km / step_km > COVERAGE_MAX_STEPS:
return web.json_response(
{"error": f"radius_km / step_km must not exceed {COVERAGE_MAX_STEPS}"}, status=400
)
node = await store.get_node(node_id)
if not node or not node.last_lat or not node.last_long:
return web.json_response({"error": "Node not found or missing location"}, status=404)
@@ -1246,8 +1287,11 @@ async def api_coverage(request):
lon = node.last_long * 1e-7
mode = request.query.get("mode", "perimeter")
# The ITM model is pure CPU work; keep it off the event loop so other requests
# are still served while it runs.
if mode == "perimeter":
perimeter = compute_perimeter(
perimeter = await asyncio.to_thread(
compute_perimeter,
lat=round(lat, 7),
lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3),
@@ -1263,7 +1307,8 @@ async def api_coverage(request):
{"mode": "perimeter", "threshold_dbm": threshold_dbm, "perimeter": perimeter}
)
points = compute_coverage(
points = await asyncio.to_thread(
compute_coverage,
lat=round(lat, 7),
lon=round(lon, 7),
freq_mhz=round(freq_mhz, 3),
+13
View File
@@ -59,6 +59,19 @@ firehose_interal=3
weekly_net_message = Weekly Mesh check-in. We will keep it open on every Wednesday from 5:00pm for checkins. The message format should be (LONG NAME) - (CITY YOU ARE IN) #BayMeshNet.
net_tag = #BayMeshNet
# Map channel color overrides. Use six-digit hex colors; other channels get generated colors.
# Names ignore case, spaces, hyphens, and underscores. Restart the web server after editing.
[map_channel_colors]
LongFast = #4363d8
LongSlow = #000075
VeryLongSlow = #808000
MediumSlow = #008080
MediumFast = #e6194b
ShortSlow = #911eb4
ShortFast = #f58231
LongModerate = #46f0f0
ShortTurbo = #f032e6
# -------------------------
# MQTT Broker Configuration
# -------------------------
+6 -1
View File
@@ -338,11 +338,16 @@ async def load_database_from_mqtt(
mqtt_user: str | None = None,
mqtt_passwd: str | None = None,
):
logger = logging.getLogger(__name__)
async for topic, env in mqtt_reader.get_topic_envelopes(
mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd
):
async with db_lock: # Block if cleanup is running
await mqtt_store.process_envelope(topic, env)
try:
await mqtt_store.process_envelope(topic, env)
except Exception:
# One bad or unexpected message must not stop ingestion for everyone.
logger.exception("Failed to store a message from topic %s; skipping it", topic)
# -------------------------