mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-31 03:01:58 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd94477013 | ||
|
|
5559c84a58 | ||
|
|
9952426aee | ||
|
|
df7766e783 | ||
|
|
fb3a0154dd | ||
|
|
012150cba9 | ||
|
|
ef37a910b9 | ||
|
|
f4fbf16e4f | ||
|
|
f9b684ee44 | ||
|
|
cd78c392e9 | ||
|
|
96b49bb893 | ||
|
|
5900c41cf4 | ||
|
|
5214b233c1 | ||
|
|
0109d68c1b | ||
|
|
81612128a5 | ||
|
|
bb8da4cc13 | ||
|
|
325e685209 | ||
|
|
0850c433f2 | ||
|
|
28651cad7b | ||
|
|
4df2072a72 | ||
|
|
3c66597b65 | ||
|
|
64486dabc8 | ||
|
|
9d3bd891b7 | ||
|
|
03943c463e | ||
|
|
849ab69566 | ||
|
|
c1d21d1ce1 | ||
|
|
4329ccc0f3 | ||
|
|
116a1c30b3 | ||
|
|
295f0f086e | ||
|
|
4988b887c7 | ||
|
|
40ed330523 | ||
|
|
728072dfac | ||
|
|
4d036299f0 | ||
|
|
0657899a6f | ||
|
|
ad1f404f4a | ||
|
|
424998090d | ||
|
|
b3b95dbb81 | ||
|
|
88fe764e7b | ||
|
|
87344d4902 | ||
|
|
dc7ddb5bf3 | ||
|
|
d1e1e1a91f | ||
|
|
81535c08b1 | ||
|
|
41dbd12ed2 | ||
|
|
d78de2fd0b | ||
|
|
85753d8704 | ||
|
|
e9e66b2dfb |
No files matched your search
+6
-9
@@ -79,8 +79,6 @@ How to report:
|
||||
- Use a **clear and descriptive title**.
|
||||
- Include reproduction steps and expected vs. actual behavior.
|
||||
|
||||
⚠️ Security issues should **not** be reported in public issues. Instead, email us at **meshview-maintainers@proton.me**.
|
||||
|
||||
---
|
||||
|
||||
### Suggesting Enhancements
|
||||
@@ -103,12 +101,11 @@ When submitting:
|
||||
We love first-time contributors! 🚀
|
||||
|
||||
If you’d like to start coding:
|
||||
1. Look for issues tagged with [good first issue](../../issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
2. Fork the repository and clone it locally.
|
||||
3. Set up the development environment:
|
||||
4. Run the app locally
|
||||
5. Create a new branch, make your changes, commit, and push.
|
||||
6. Open a pull request!
|
||||
1. Fork the repository and clone it locally.
|
||||
2. Set up the development environment:
|
||||
3. Run the app locally
|
||||
4. Create a new branch from `develop`, make your changes, commit, and push.
|
||||
5. Open a pull request against the `develop` branch.
|
||||
|
||||
---
|
||||
|
||||
@@ -130,4 +127,4 @@ Start by contributing regularly, engaging in issues/PRs, and helping others.
|
||||
|
||||
---
|
||||
|
||||
✨ That’s it! Thanks again for being part of Meshview. Every contribution matters.
|
||||
✨ That’s it! Thanks again for being part of Meshview. Every contribution matters.
|
||||
@@ -4,6 +4,25 @@
|
||||
|
||||
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into network activity, including message traffic, node positions, and telemetry data.
|
||||
|
||||
### Version 3.0.9 — October 2026
|
||||
|
||||
- Live map: added animated packet traces from senders to reporting gateways and configurable channel colors through the optional `[map_channel_colors]` section in `config.ini`.
|
||||
- Daily snapshots: new snapshots report activity for the previous complete UTC day, including newly imported packets, unique packet senders observed that day, and gateways that reported packets that day. Existing historical snapshots keep their original counts.
|
||||
- Node tracking: record nodes from packet activity before their node information arrives, and preserve known positions when map reports have missing or invalid coordinates.
|
||||
- Neighbor SNR chart: show the last seven days and the 15 neighbors with the most data points, distinguish neighbors with identical short names, and fix CSV export.
|
||||
- MQTT reliability: skip malformed messages and invalid node IDs without stopping packet ingestion.
|
||||
- Security and privacy: escape mesh-provided text in web pages, restrict language and static file requests to their intended directories, and prevent payload searches from matching redacted direct messages.
|
||||
- Coverage API: validate and bound request parameters, and run coverage calculations off the event loop to keep the web server responsive.
|
||||
|
||||
### Version 3.0.8 — September 2026
|
||||
- Map updates: added OpenTopoMap as a selectable base map, improved map controls, and fixed several map/node display edge cases.
|
||||
- Traceroute: added a dedicated traceroute page, corrected route path orientation in graph edges, and improved traceroute API/documentation support.
|
||||
- Routing display: show ROUTING ACK/NAK packets clearly instead of reporting misleading `error_reason: NONE` values.
|
||||
- MQTT/decryption: expanded Meshtastic one-byte PSK shorthand support in `mqtt.secondary_keys`.
|
||||
- Health/maintenance: added cleanup status reporting to the health page and fixed timezone handling in health reporting.
|
||||
- UI/API fixes: improved Firehose, Node, and Top channel handling, including showing all channels where expected.
|
||||
- Contributors: thanks to [giannoug](https://github.com/giannoug) for fixes to ROUTING ACK/NAK display, `mqtt.secondary_keys` PSK shorthand handling, and traceroute path orientation, plus jschrempp and the Meshview community for testing and feedback included in this release.
|
||||
|
||||
### Version 3.0.7 — May 2026
|
||||
- Reliability report: added a dedicated reliability page for checking how well a selected node is heard across the mesh, including heard/missed status, packet counts, hop counts, gateway tables, and a map view.
|
||||
- Node list/map UX: added node list filtering for wider active-node ranges, included all database nodes where appropriate, and made map node colors persistent across reboots.
|
||||
@@ -13,7 +32,7 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
|
||||
- Tooling: improved the protobuf updater so it supports raw commit SHAs, the newer upstream `meshtastic/*.proto` layout, generated `.pyi` stubs, and the vendored `nanopb`/`serial_hal` outputs.
|
||||
|
||||
### 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.
|
||||
- Daily snapshots: added the `daily_snapshot` table, scheduled snapshot capture, and `/api/snapshots/daily` for historical node, packet, and gateway counts. These originally stored database totals; version 3.0.9 changes new snapshots to daily activity counts.
|
||||
- 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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "3.0.8"
|
||||
__version__ = "3.0.9"
|
||||
__release_date__ = "2026-5-12"
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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 = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
|
||||
window.escapeHtml = function escapeHtml(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
return String(value).replace(/[&<>"']/g, ch => MAP[ch]);
|
||||
};
|
||||
})();
|
||||
@@ -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)){
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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, """)
|
||||
: "";
|
||||
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, "<")
|
||||
.replace(/>/g, ">");
|
||||
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>
|
||||
|
||||
+167
-27
@@ -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,29 +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;
|
||||
|
||||
const palette = ["#bcf60c","#fabebe","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#ffd8b1",
|
||||
"#808080"];
|
||||
// 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();
|
||||
@@ -276,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;
|
||||
@@ -392,6 +420,7 @@ function fetchNewPackets(){
|
||||
} else {
|
||||
addUnmappedPacket(pkt, nodeData);
|
||||
}
|
||||
schedulePacketSeenNodes(pkt);
|
||||
});
|
||||
|
||||
lastImportTime = latest;
|
||||
@@ -427,6 +456,7 @@ map.on("overlayadd", e => {
|
||||
|
||||
map.on("overlayremove", e => {
|
||||
if(e.layer === nodeLayer) clearActiveBlinks();
|
||||
if(e.layer === packetSeenLayer) clearPacketSeenHighlights();
|
||||
});
|
||||
|
||||
async function waitForConfig() {
|
||||
@@ -435,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);
|
||||
@@ -483,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=>({
|
||||
@@ -750,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 });
|
||||
@@ -766,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;
|
||||
|
||||
@@ -773,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();
|
||||
@@ -796,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 });
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 === "<" ? "<" : ">"));
|
||||
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" }
|
||||
},
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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;">
|
||||
|
||||
@@ -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 => ({
|
||||
"&":"&",
|
||||
"<":"<",
|
||||
">":">",
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
+47
-3
@@ -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,11 @@ 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 +803,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 +1247,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 +1273,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 +1286,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 +1306,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),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
# -------------------------
|
||||
|
||||
Reference in new issue
Block a user