mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-31 03:01:58 +01:00
Compare commits
24
Commits
develop-4
...
7c8f5c42d0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8f5c42d0 | ||
|
|
11b6a92f71 | ||
|
|
d022ec455e | ||
|
|
acba2284c5 | ||
|
|
dddc8e0a13 | ||
|
|
93fc31efd4 | ||
|
|
3e1d95ed2c | ||
|
|
ec9fafc362 | ||
|
|
0e424da10a | ||
|
|
7ad590a83c | ||
|
|
b80a0b27df | ||
|
|
330ee47932 | ||
|
|
29860e8e7d | ||
|
|
ef52e96d7c | ||
|
|
acfd71bd2e | ||
|
|
f570fdb65a | ||
|
|
11fb764fa8 | ||
|
|
c0c9a499d5 | ||
|
|
cfa71f9235 | ||
|
|
5f3d37bf06 | ||
|
|
1e9b1a3ce3 | ||
|
|
d66c5d21e9 | ||
|
|
a5f46d6360 | ||
|
|
ae7ba15263 |
No files matched your search
@@ -0,0 +1,29 @@
|
||||
"""add last altitude to node
|
||||
|
||||
Revision ID: 44612e74a798
|
||||
Revises: 4d2a7c9e1b30
|
||||
Create Date: 2026-10-28 10:37:11.340792
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '44612e74a798'
|
||||
down_revision: str | None = '4d2a7c9e1b30'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("node") as batch_op:
|
||||
batch_op.add_column(sa.Column("last_altitude", sa.BigInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("node") as batch_op:
|
||||
batch_op.drop_column("last_altitude")
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Add packet index on from_node_id, portnum and import_time_us
|
||||
|
||||
Revision ID: 4d2a7c9e1b30
|
||||
Revises: e8f2c4b6d9a1
|
||||
Create Date: 2026-10-26 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "4d2a7c9e1b30"
|
||||
down_revision: str | None = "e8f2c4b6d9a1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
INDEX_NAME = "idx_packet_from_node_portnum_time_us"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Lets the node QR endpoint read a node's NODEINFO packets without scanning all its packets.
|
||||
inspector = sa.inspect(op.get_bind())
|
||||
if INDEX_NAME not in {index["name"] for index in inspector.get_indexes("packet")}:
|
||||
op.create_index(
|
||||
INDEX_NAME,
|
||||
"packet",
|
||||
["from_node_id", "portnum", sa.text("import_time_us DESC")],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(INDEX_NAME, table_name="packet")
|
||||
@@ -73,6 +73,7 @@ Response Example
|
||||
"role": "client",
|
||||
"last_lat": 377749000,
|
||||
"last_long": -1224194000,
|
||||
"last_altitude": 125,
|
||||
"channel": "main",
|
||||
"is_mqtt_gateway": true,
|
||||
"first_seen_us": 1736283723456789,
|
||||
@@ -82,6 +83,12 @@ Response Example
|
||||
}
|
||||
```
|
||||
|
||||
`last_altitude` is the latest reported altitude in meters above sea level, or `null`
|
||||
when unknown. It updates with valid node locations from position packets and map reports.
|
||||
Position packets without altitude preserve the previous value. Map reports lack altitude
|
||||
field presence, so zero values in map reports also preserve the previous value.
|
||||
Existing nodes receive altitude as new reports arrive.
|
||||
|
||||
---
|
||||
|
||||
## 2. Packets API
|
||||
@@ -96,6 +103,7 @@ Query Parameters
|
||||
- `portnum` (optional, int): Filter by port number.
|
||||
- `contains` (optional, string): Payload substring filter.
|
||||
- `from_node_id` (optional, int): Filter by sender node ID.
|
||||
- `channel` (optional, string): Filter by exact channel name before applying the packet limit.
|
||||
- `to_node_id` (optional, int): Filter by recipient node ID.
|
||||
- `node_id` (optional, int): Legacy filter matching either from or to node ID.
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "3.0.9"
|
||||
__release_date__ = "2026-11-12"
|
||||
__version__ = "4.0.0"
|
||||
__release_date__ = "2026-11-01"
|
||||
|
||||
|
||||
def get_git_revision():
|
||||
|
||||
@@ -17,18 +17,43 @@ def text_message(payload):
|
||||
return payload.decode("utf-8")
|
||||
|
||||
|
||||
# The firmware keeps at most 8 hops in each direction (none of 66,709 real traceroutes in 17
|
||||
# days of bay-area traffic had more). A longer route can only be forged, and one with
|
||||
# thousands of hops makes every page that draws it slow, so treat it as undecodable.
|
||||
MAX_ROUTE_HOPS = 8
|
||||
|
||||
|
||||
def route_discovery(payload):
|
||||
route = RouteDiscovery.FromString(payload)
|
||||
if max(len(route.route), len(route.route_back)) > MAX_ROUTE_HOPS:
|
||||
return None
|
||||
return route
|
||||
|
||||
|
||||
DECODE_MAP = {
|
||||
PortNum.POSITION_APP: Position.FromString,
|
||||
PortNum.NEIGHBORINFO_APP: NeighborInfo.FromString,
|
||||
PortNum.NODEINFO_APP: User.FromString,
|
||||
PortNum.TELEMETRY_APP: Telemetry.FromString,
|
||||
PortNum.TRACEROUTE_APP: RouteDiscovery.FromString,
|
||||
PortNum.TRACEROUTE_APP: route_discovery,
|
||||
PortNum.ROUTING_APP: Routing.FromString,
|
||||
PortNum.TEXT_MESSAGE_APP: text_message,
|
||||
PortNum.MAP_REPORT_APP: MapReport.FromString,
|
||||
}
|
||||
|
||||
|
||||
def enum_name(enum_type, value):
|
||||
"""Return the name of a protobuf enum value, or ``unknown(N)`` if it is not defined.
|
||||
|
||||
These values come from packets on the mesh, so a newer firmware or a forged packet
|
||||
can carry a number the bundled protobufs do not know. ``Name()`` raises on those.
|
||||
"""
|
||||
try:
|
||||
return enum_type.Name(value)
|
||||
except ValueError:
|
||||
return f"unknown({value})"
|
||||
|
||||
|
||||
def decode_payload(portnum, payload):
|
||||
if portnum not in DECODE_MAP:
|
||||
return None
|
||||
|
||||
@@ -246,7 +246,9 @@
|
||||
"share_contact_qr": "Share Contact QR",
|
||||
"copy_url": "Copy URL",
|
||||
"copied": "Copied!",
|
||||
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device."
|
||||
"scan_qr_to_add": "Scan this QR code to add this node as a contact on another device.",
|
||||
"qr_no_public_key": "No public key heard from this node yet, so it cannot be shared as a contact.",
|
||||
"qr_key_conflict": "This node has reported more than one public key, so it is not shared as a contact."
|
||||
},
|
||||
"packet": {
|
||||
"loading": "Loading packet information...",
|
||||
|
||||
@@ -232,7 +232,9 @@
|
||||
"share_contact_qr": "Compartir contacto QR",
|
||||
"copy_url": "Copiar URL",
|
||||
"copied": "¡Copiado!",
|
||||
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo."
|
||||
"scan_qr_to_add": "Escanea este código QR para agregar este nodo como contacto en otro dispositivo.",
|
||||
"qr_no_public_key": "Aún no se ha recibido la clave pública de este nodo, por lo que no se puede compartir como contacto.",
|
||||
"qr_key_conflict": "Este nodo ha informado más de una clave pública, por lo que no se comparte como contacto."
|
||||
},
|
||||
|
||||
"packet": {
|
||||
|
||||
@@ -227,7 +227,9 @@
|
||||
"share_contact_qr": "Поделиться QR контакта",
|
||||
"copy_url": "Копировать URL",
|
||||
"copied": "Скопировано!",
|
||||
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве."
|
||||
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве.",
|
||||
"qr_no_public_key": "Открытый ключ этой ноды ещё не получен, поэтому её нельзя отправить как контакт.",
|
||||
"qr_key_conflict": "Эта нода сообщила более одного открытого ключа, поэтому она не отправляется как контакт."
|
||||
},
|
||||
"packet": {
|
||||
"loading": "Загрузка информации о пакете...",
|
||||
|
||||
@@ -21,6 +21,7 @@ class Node(Base):
|
||||
role: Mapped[str] = mapped_column(nullable=True)
|
||||
last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
last_long: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
last_altitude: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
channel: Mapped[str] = mapped_column(nullable=True)
|
||||
is_mqtt_gateway: Mapped[bool] = mapped_column(nullable=True)
|
||||
first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
@@ -59,6 +60,12 @@ class Packet(Base):
|
||||
Index("idx_packet_to_node_id", "to_node_id"),
|
||||
Index("idx_packet_import_time_us", desc("import_time_us")),
|
||||
Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
|
||||
Index(
|
||||
"idx_packet_from_node_portnum_time_us",
|
||||
"from_node_id",
|
||||
"portnum",
|
||||
desc("import_time_us"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+26
-2
@@ -130,9 +130,13 @@ def decrypt(packet, key):
|
||||
try:
|
||||
data = Data()
|
||||
data.ParseFromString(raw_proto)
|
||||
packet.decoded.CopyFrom(data)
|
||||
except DecodeError:
|
||||
return False
|
||||
# AES-CTR has no authentication: a wrong key can still parse as protobuf
|
||||
# containing only unknown fields. Do not let it prevent trying the next key.
|
||||
if data.portnum == 0:
|
||||
return False
|
||||
packet.decoded.CopyFrom(data)
|
||||
return True
|
||||
|
||||
|
||||
@@ -159,15 +163,35 @@ async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_pa
|
||||
if start_time is None:
|
||||
start_time = time.time()
|
||||
|
||||
confirmed_secondary_keys = set()
|
||||
async for msg in client.messages:
|
||||
try:
|
||||
envelope = ServiceEnvelope.FromString(msg.payload)
|
||||
except DecodeError:
|
||||
continue
|
||||
|
||||
for key in keyring:
|
||||
was_encrypted = envelope.packet.HasField("encrypted")
|
||||
for key_index, key in enumerate(keyring):
|
||||
if decrypt(envelope.packet, key):
|
||||
if (
|
||||
was_encrypted
|
||||
and key_index > 0
|
||||
and key_index not in confirmed_secondary_keys
|
||||
):
|
||||
logger.info(
|
||||
"Successfully decrypted MQTT packet using secondary key #%d",
|
||||
key_index,
|
||||
)
|
||||
confirmed_secondary_keys.add(key_index)
|
||||
break
|
||||
else:
|
||||
logger.debug(
|
||||
"Unable to decrypt MQTT packet id=%d channel=%d topic=%r with configured keys",
|
||||
envelope.packet.id,
|
||||
envelope.packet.channel,
|
||||
msg.topic.value,
|
||||
)
|
||||
continue
|
||||
if not envelope.packet.decoded:
|
||||
continue
|
||||
|
||||
|
||||
+17
-22
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -44,7 +45,8 @@ def parse_node_id(value: str | None) -> int | None:
|
||||
|
||||
|
||||
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
|
||||
"""Exclude the box within one degree of (0, 0); coordinates use 1e-7 degrees."""
|
||||
return lat is not None and lon is not None and (abs(lat) > 10_000_000 or abs(lon) > 10_000_000)
|
||||
|
||||
|
||||
def node_user_id(node_id: int) -> str:
|
||||
@@ -202,16 +204,8 @@ async def process_envelope(topic, env):
|
||||
|
||||
async with mqtt_database.async_session() as session:
|
||||
try:
|
||||
hw_model = (
|
||||
HardwareModel.Name(map_report.hw_model)
|
||||
if hasattr(HardwareModel, "Name")
|
||||
else "unknown"
|
||||
)
|
||||
role = (
|
||||
Config.DeviceConfig.Role.Name(map_report.role)
|
||||
if hasattr(Config.DeviceConfig.Role, "Name")
|
||||
else "unknown"
|
||||
)
|
||||
hw_model = decode_payload.enum_name(HardwareModel, map_report.hw_model)
|
||||
role = decode_payload.enum_name(Config.DeviceConfig.Role, map_report.role)
|
||||
node = (
|
||||
await session.execute(select(Node).where(Node.node_id == node_id))
|
||||
).scalar_one_or_none()
|
||||
@@ -230,6 +224,9 @@ async def process_envelope(topic, env):
|
||||
if has_location:
|
||||
node.last_lat = map_report.latitude_i
|
||||
node.last_long = map_report.longitude_i
|
||||
# MapReport has no presence flag for altitude.
|
||||
if map_report.altitude != 0:
|
||||
node.last_altitude = map_report.altitude
|
||||
node.firmware = map_report.firmware_version
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
@@ -250,6 +247,9 @@ async def process_envelope(topic, env):
|
||||
if has_location:
|
||||
node.last_lat = map_report.latitude_i
|
||||
node.last_long = map_report.longitude_i
|
||||
# MapReport has no presence flag for altitude.
|
||||
if map_report.altitude != 0:
|
||||
node.last_altitude = map_report.altitude
|
||||
session.add(node)
|
||||
except Exception as e:
|
||||
print(f"Error processing MAP_REPORT_APP: {e}")
|
||||
@@ -326,7 +326,8 @@ async def process_envelope(topic, env):
|
||||
"node_id": gateway_node_id,
|
||||
"channel": env.channel_id,
|
||||
"rx_time": env.packet.rx_time,
|
||||
"rx_snr": env.packet.rx_snr,
|
||||
# JSON cannot carry inf/nan, and they are not real SNR readings.
|
||||
"rx_snr": env.packet.rx_snr if math.isfinite(env.packet.rx_snr) else None,
|
||||
"rx_rssi": env.packet.rx_rssi,
|
||||
"hop_limit": env.packet.hop_limit,
|
||||
"hop_start": env.packet.hop_start,
|
||||
@@ -374,16 +375,8 @@ async def process_envelope(topic, env):
|
||||
await session.commit()
|
||||
return
|
||||
|
||||
hw_model = (
|
||||
HardwareModel.Name(user.hw_model)
|
||||
if user.hw_model in HardwareModel.values()
|
||||
else f"unknown({user.hw_model})"
|
||||
)
|
||||
role = (
|
||||
Config.DeviceConfig.Role.Name(user.role)
|
||||
if hasattr(Config.DeviceConfig.Role, "Name")
|
||||
else "unknown"
|
||||
)
|
||||
hw_model = decode_payload.enum_name(HardwareModel, user.hw_model)
|
||||
role = decode_payload.enum_name(Config.DeviceConfig.Role, user.role)
|
||||
|
||||
node = (
|
||||
await session.execute(select(Node).where(Node.id == user.id))
|
||||
@@ -440,6 +433,8 @@ async def process_envelope(topic, env):
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
node.last_lat = position.latitude_i
|
||||
node.last_long = position.longitude_i
|
||||
if position.HasField("altitude"):
|
||||
node.last_altitude = position.altitude
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
node.first_seen_us = now_us
|
||||
|
||||
@@ -63,6 +63,10 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
.mesh-theme-toggle { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border:1px solid #ffffff70; border-radius:6px; background:transparent; color:white; font:22px/1 system-ui,sans-serif; cursor:pointer; }
|
||||
.mesh-header .mesh-theme-toggle { margin-left:auto; }
|
||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
||||
.mesh-theme-toggle .theme-icon { width:22px; height:22px; flex-shrink:0; }
|
||||
.mesh-theme-toggle .theme-icon--sun { display:none; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--sun { display:block; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--moon { display:none; }
|
||||
.mesh-theme-toggle:focus-visible { outline:2px solid white; outline-offset:3px; }
|
||||
@media(max-width:1000px) { .mesh-header .mesh-menu-toggle { margin-left:auto; } .mesh-header .mesh-theme-toggle { margin-left:12px; } .mesh-header #site-menu { order:4; } }
|
||||
[data-bs-theme="dark"] :is(.chat-status,.chat-details,.chat-channel,.chat-status[data-state="paused"],.chat-status[data-state="loading"],.good-x) { background:var(--mesh-soft); color:var(--mesh-primary); border-color:var(--mesh-border); }
|
||||
@@ -88,7 +92,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
<!-- mesh-theme:end -->
|
||||
</head>
|
||||
<body>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false">☾</button><main>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><svg class="theme-icon theme-icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="theme-icon theme-icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z"/></svg></button><main>
|
||||
<h1>Chat messages API</h1>
|
||||
<p>Chat uses <code>GET /api/packets?portnum=1</code>. There is no separate <code>/api/chat</code> endpoint.</p>
|
||||
<p>Use <code>limit</code> and <code>since</code> for incremental polling. Direct port-1 text messages are returned as <code><redacted></code>.</p>
|
||||
|
||||
@@ -87,6 +87,10 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
.mesh-theme-toggle { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border:1px solid #ffffff70; border-radius:6px; background:transparent; color:white; font:22px/1 system-ui,sans-serif; cursor:pointer; }
|
||||
.mesh-header .mesh-theme-toggle { margin-left:auto; }
|
||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
||||
.mesh-theme-toggle .theme-icon { width:22px; height:22px; flex-shrink:0; }
|
||||
.mesh-theme-toggle .theme-icon--sun { display:none; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--sun { display:block; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--moon { display:none; }
|
||||
.mesh-theme-toggle:focus-visible { outline:2px solid white; outline-offset:3px; }
|
||||
@media(max-width:1000px) { .mesh-header .mesh-menu-toggle { margin-left:auto; } .mesh-header .mesh-theme-toggle { margin-left:12px; } .mesh-header #site-menu { order:4; } }
|
||||
[data-bs-theme="dark"] :is(.chat-status,.chat-details,.chat-channel,.chat-status[data-state="paused"],.chat-status[data-state="loading"],.good-x) { background:var(--mesh-soft); color:var(--mesh-primary); border-color:var(--mesh-border); }
|
||||
@@ -111,7 +115,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<header><div class="header-inner"><a class="brand" href="/"><img src="/static/meshview-icon.svg" alt="" width="32" height="32">Meshview</a><span>API reference</span><a class="home-link" href="/">Home ↗</a><button class="mesh-theme-toggle" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false">☾</button></div></header>
|
||||
<header><div class="header-inner"><a class="brand" href="/"><img src="/static/meshview-icon.svg" alt="" width="32" height="32">Meshview</a><span>API reference</span><a class="home-link" href="/">Home ↗</a><button class="mesh-theme-toggle" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><svg class="theme-icon theme-icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="theme-icon theme-icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z"/></svg></button></div></header>
|
||||
<main>
|
||||
<details class="api-index"><summary>Browse all 16 endpoints</summary><nav aria-label="API endpoints"><ul><li><a href="#get-api-nodes">GET /api/nodes</a></li><li><a href="#get-api-packets">GET /api/packets</a></li><li><a href="#get-api-channels">GET /api/channels</a></li><li><a href="#get-api-stats">GET /api/stats</a></li><li><a href="#get-api-stats-count">GET /api/stats/count</a></li><li><a href="#get-api-stats-top">GET /api/stats/top</a></li><li><a href="#get-api-edges">GET /api/edges</a></li><li><a href="#get-api-snapshots-daily">GET /api/snapshots/daily</a></li><li><a href="#get-api-config">GET /api/config</a></li><li><a href="#get-api-lang">GET /api/lang</a></li><li><a href="#get-api-packets-seen-packet-id">GET /api/packets_seen/{packet_id}</a></li><li><a href="#get-api-traceroute-packet-id">GET /api/traceroute/{packet_id}</a></li><li><a href="#get-health">GET /health</a></li><li><a href="#get-version">GET /version</a></li><li><a href="#get-api-node-node-id-qr">GET /api/node/{node_id}/qr</a></li><li><a href="#get-api-coverage-node-id">GET /api/coverage/{node_id}</a></li></ul></nav></details>
|
||||
<h1 id="api-documentation">API Documentation</h1>
|
||||
@@ -157,7 +161,7 @@ curl 'http://localhost:8081/api/coverage/1234?mode=perimeter&radius_km=2
|
||||
<section class="endpoint" id="get-api-packets"><h3><span class="method">GET</span> <code>/api/packets</code></h3>
|
||||
<p>Returns packets with optional filters.</p>
|
||||
<p>Query Parameters</p>
|
||||
<ul><li><code>packet_id</code> (optional, decimal int): Return one packet by database row ID (overrides other filters).</li><li><code>limit</code> (optional, int): Max packets to return, clamped 1-1000. Default: <code>50</code>.</li><li><code>since</code> (optional, int): Only packets imported after this microsecond timestamp.</li><li><code>portnum</code> (optional, int): Filter by port number.</li><li><code>contains</code> (optional, string): Payload substring filter.</li><li><code>from_node_id</code> (optional, int): Filter by sender node ID.</li><li><code>to_node_id</code> (optional, int): Filter by recipient node ID.</li><li><code>node_id</code> (optional, int): Legacy filter matching either from or to node ID.</li></ul>
|
||||
<ul><li><code>packet_id</code> (optional, decimal int): Return one packet by database row ID (overrides other filters).</li><li><code>limit</code> (optional, int): Max packets to return, clamped 1-1000. Default: <code>50</code>.</li><li><code>since</code> (optional, int): Only packets imported after this microsecond timestamp.</li><li><code>portnum</code> (optional, int): Filter by port number.</li><li><code>contains</code> (optional, string): Payload substring filter.</li><li><code>from_node_id</code> (optional, int): Filter by sender node ID.</li><li><code>channel</code> (optional, string): Filter by exact channel name before applying the packet limit.</li><li><code>to_node_id</code> (optional, int): Filter by recipient node ID.</li><li><code>node_id</code> (optional, int): Legacy filter matching either from or to node ID.</li></ul>
|
||||
<p>Response Example</p>
|
||||
<pre><code class="language-json">{
|
||||
"packets": [
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
/* Shared CSV helpers for the "Export CSV" buttons. Node names, channels, firmware strings and
|
||||
message text come from the mesh, so every cell is quoted with inner quotes doubled, and text
|
||||
that a spreadsheet would run as a formula (a leading = + - @, tab or carriage return) gets a
|
||||
leading ' so it opens as plain text. Plain numbers, including negative ones, are unchanged. */
|
||||
(function () {
|
||||
const NUMBER = /^[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?$/;
|
||||
const FORMULA = /^\s*[=+\-@]|^[\t\r]/;
|
||||
window.csvCell = function csvCell(value) {
|
||||
if (value === null || value === undefined) return '""';
|
||||
let text = String(value);
|
||||
if (FORMULA.test(text) && !NUMBER.test(text.trim())) text = "'" + text;
|
||||
return '"' + text.replace(/"/g, '""') + '"';
|
||||
};
|
||||
window.csvFromRows = function csvFromRows(rows) {
|
||||
return rows.map(row => row.map(csvCell).join(",")).join("\n");
|
||||
};
|
||||
})();
|
||||
@@ -70,6 +70,10 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
.mesh-theme-toggle { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border:1px solid #ffffff70; border-radius:6px; background:transparent; color:white; font:22px/1 system-ui,sans-serif; cursor:pointer; }
|
||||
.mesh-header .mesh-theme-toggle { margin-left:auto; }
|
||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
||||
.mesh-theme-toggle .theme-icon { width:22px; height:22px; flex-shrink:0; }
|
||||
.mesh-theme-toggle .theme-icon--sun { display:none; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--sun { display:block; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--moon { display:none; }
|
||||
.mesh-theme-toggle:focus-visible { outline:2px solid white; outline-offset:3px; }
|
||||
@media(max-width:1000px) { .mesh-header .mesh-menu-toggle { margin-left:auto; } .mesh-header .mesh-theme-toggle { margin-left:12px; } .mesh-header #site-menu { order:4; } }
|
||||
[data-bs-theme="dark"] :is(.chat-status,.chat-details,.chat-channel,.chat-status[data-state="paused"],.chat-status[data-state="loading"],.good-x) { background:var(--mesh-soft); color:var(--mesh-primary); border-color:var(--mesh-border); }
|
||||
@@ -95,7 +99,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
<!-- mesh-theme:end -->
|
||||
</head>
|
||||
<body>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false">☾</button>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><svg class="theme-icon theme-icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="theme-icon theme-icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z"/></svg></button>
|
||||
<div id="map"></div>
|
||||
<div id="legend"></div>
|
||||
|
||||
|
||||
@@ -84,6 +84,10 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
.mesh-theme-toggle { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border:1px solid #ffffff70; border-radius:6px; background:transparent; color:white; font:22px/1 system-ui,sans-serif; cursor:pointer; }
|
||||
.mesh-header .mesh-theme-toggle { margin-left:auto; }
|
||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
||||
.mesh-theme-toggle .theme-icon { width:22px; height:22px; flex-shrink:0; }
|
||||
.mesh-theme-toggle .theme-icon--sun { display:none; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--sun { display:block; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--moon { display:none; }
|
||||
.mesh-theme-toggle:focus-visible { outline:2px solid white; outline-offset:3px; }
|
||||
@media(max-width:1000px) { .mesh-header .mesh-menu-toggle { margin-left:auto; } .mesh-header .mesh-theme-toggle { margin-left:12px; } .mesh-header #site-menu { order:4; } }
|
||||
[data-bs-theme="dark"] :is(.chat-status,.chat-details,.chat-channel,.chat-status[data-state="paused"],.chat-status[data-state="loading"],.good-x) { background:var(--mesh-soft); color:var(--mesh-primary); border-color:var(--mesh-border); }
|
||||
@@ -109,7 +113,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
<!-- mesh-theme:end -->
|
||||
</head>
|
||||
<body>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false">☾</button>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><svg class="theme-icon theme-icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="theme-icon theme-icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z"/></svg></button>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
@@ -168,7 +172,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
nodes = data.nodes || [];
|
||||
} catch(err){ console.error('Failed to load nodes', err); }
|
||||
|
||||
const markers = {};
|
||||
const markers = new Map(); // channel -> markers; Map, because channel names come from the mesh
|
||||
const markerById = {}; // Keyed by numeric node_id for packets
|
||||
const nodeMap = new Map(); // Keyed by numeric node_id
|
||||
const channels = new Set();
|
||||
@@ -203,8 +207,8 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
setTimeout(()=>marker.closePopup(),3000);
|
||||
});
|
||||
|
||||
if(!markers[node.channel]) markers[node.channel]=[];
|
||||
markers[node.channel].push({marker,isRouter});
|
||||
if(!markers.has(node.channel)) markers.set(node.channel, []);
|
||||
markers.get(node.channel).push({marker,isRouter});
|
||||
});
|
||||
|
||||
// --- Filters ---
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
function updateButton() {
|
||||
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
||||
const dark = root.dataset.bsTheme === 'dark';
|
||||
button.textContent = dark ? '☀' : '☾';
|
||||
button.setAttribute('aria-label',dark ? 'Switch to day mode' : 'Switch to night mode');
|
||||
button.setAttribute('aria-pressed',String(dark));
|
||||
button.title = dark ? 'Day mode' : 'Night mode';
|
||||
|
||||
@@ -38,6 +38,7 @@ async def get_packets(
|
||||
after=None,
|
||||
contains=None, # substring search
|
||||
limit=50,
|
||||
channel=None,
|
||||
):
|
||||
async with database.async_session() as session:
|
||||
stmt = select(models.Packet)
|
||||
@@ -60,6 +61,9 @@ async def get_packets(
|
||||
)
|
||||
)
|
||||
|
||||
if channel is not None:
|
||||
conditions.append(models.Packet.channel == channel)
|
||||
|
||||
# Port filter
|
||||
if portnum is not None:
|
||||
conditions.append(models.Packet.portnum == portnum)
|
||||
@@ -113,6 +117,17 @@ async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
|
||||
return result.scalars()
|
||||
|
||||
|
||||
async def get_nodeinfo_payloads(node_id):
|
||||
"""Return the payloads of every stored NODEINFO_APP packet from a node, newest first."""
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Packet.payload)
|
||||
.where(Packet.from_node_id == node_id, Packet.portnum == PortNum.NODEINFO_APP)
|
||||
.order_by(Packet.import_time_us.desc().nulls_last())
|
||||
)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_packet(packet_id):
|
||||
async with database.async_session() as session:
|
||||
q = select(Packet).where(Packet.id == packet_id)
|
||||
@@ -141,12 +156,19 @@ async def has_packets(node_id, portnum):
|
||||
)
|
||||
|
||||
|
||||
# Every TRACEROUTE message stores a row under its packet id, so anyone who can publish can add
|
||||
# thousands. Real ones have a few dozen (one per gateway that heard it; the most seen in 17 days
|
||||
# of bay-area traffic was 72), so only the earliest rows are used.
|
||||
MAX_TRACEROUTE_ROWS = 200
|
||||
|
||||
|
||||
async def get_traceroute(packet_id):
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Traceroute)
|
||||
.where(Traceroute.packet_id == packet_id)
|
||||
.order_by(Traceroute.import_time_us)
|
||||
.limit(MAX_TRACEROUTE_ROWS)
|
||||
)
|
||||
return result.scalars()
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
<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>
|
||||
<script src="/static/csv.js"></script>
|
||||
<script src="/static/packet-arrival.js"></script>
|
||||
|
||||
{% block head %}{% endblock %}
|
||||
@@ -165,7 +166,7 @@ input[type="checkbox"],input[type="radio"] { accent-color:var(--mesh-primary); }
|
||||
<a class="mesh-brand" href="/"><img class="mesh-brand-icon" src="/static/meshview-icon.svg" width="32" height="32" alt=""> <span id="site-header">Meshview</span></a>
|
||||
<button class="mesh-menu-toggle" type="button" aria-label="Toggle navigation" aria-expanded="false" aria-controls="site-menu">☰</button>
|
||||
<nav id="site-menu" aria-label="Main navigation"></nav>
|
||||
<button class="mesh-theme-toggle" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false" title="Night mode">☾</button>
|
||||
<button class="mesh-theme-toggle" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false" title="Night mode"><svg class="theme-icon theme-icon--sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2m0 16v2M2 12h2m16 0h2M4.93 4.93l1.42 1.42m11.3 11.3 1.42 1.42M4.93 19.07l1.42-1.42m11.3-11.3 1.42-1.42"/></svg><svg class="theme-icon theme-icon--moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20.9 13A9 9 0 0 1 11 3.1 9 9 0 1 0 20.9 13Z"/></svg></button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="site-message" class="mesh-announcement"></div>
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
{% block css %}
|
||||
{% include "data-page-styles.html" %}
|
||||
<style>
|
||||
.node-info { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; margin-bottom:20px; font-size:13px; }
|
||||
.node-info { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:16px; margin-bottom:20px; font-size:13px; }
|
||||
.node-info-col { background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; padding:20px; overflow-wrap:anywhere; }
|
||||
.node-info-col > div { padding:5px 0; }
|
||||
.node-info-col h2 { margin:0 0 10px; padding-bottom:10px; border-bottom:1px solid var(--mesh-border); color:var(--mesh-text); font-size:15px; font-weight:600; }
|
||||
.node-info strong { color:var(--mesh-muted); font-weight:500; }
|
||||
.node-actions .qr-unavailable { align-self:center; color:var(--mesh-muted); font-size:13px; }
|
||||
.node-actions { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:20px; }
|
||||
.chart-container { width:100%; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; margin-bottom:20px; overflow:hidden; }
|
||||
.chart-header { display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:10px; padding:16px 20px; border-bottom:1px solid var(--mesh-border); font-size:14px; font-weight:600; }
|
||||
@@ -44,6 +46,12 @@
|
||||
<button onclick="showQrCode()" id="showQrBtn">
|
||||
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
|
||||
</button>
|
||||
<span class="qr-unavailable" id="qrUnavailable" style="display:none;" data-translate-lang="qr_no_public_key">
|
||||
No public key heard from this node yet, so it cannot be shared as a contact.
|
||||
</span>
|
||||
<span class="qr-unavailable" id="qrKeyConflict" style="display:none;" data-translate-lang="qr_key_conflict">
|
||||
This node has reported more than one public key, so it is not shared as a contact.
|
||||
</span>
|
||||
<button onclick="openReachReport()" id="reachReportBtn">
|
||||
<span>📈</span> <span>Reliability</span>
|
||||
</button>
|
||||
@@ -57,24 +65,31 @@
|
||||
|
||||
<!-- Node Info -->
|
||||
<div id="node-info" class="node-info">
|
||||
<div class="node-info-col">
|
||||
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id">—</span></div>
|
||||
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id">—</span></div>
|
||||
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name">—</span></div>
|
||||
<section class="node-info-col" aria-labelledby="node-info-heading-0">
|
||||
<h2 id="node-info-heading-0">Identity</h2>
|
||||
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name">—</span></div>
|
||||
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name">—</span></div>
|
||||
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id">—</span></div>
|
||||
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id">—</span></div>
|
||||
</section>
|
||||
<section class="node-info-col" aria-labelledby="node-info-heading-1">
|
||||
<h2 id="node-info-heading-1">Device & network</h2>
|
||||
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model">—</span></div>
|
||||
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware">—</span></div>
|
||||
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role">—</span></div>
|
||||
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel">—</span></div>
|
||||
</div>
|
||||
<div class="node-info-col">
|
||||
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway">—</span></div>
|
||||
</section>
|
||||
<section class="node-info-col" aria-labelledby="node-info-heading-2">
|
||||
<h2 id="node-info-heading-2">Location</h2>
|
||||
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat">—</span></div>
|
||||
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon">—</span></div>
|
||||
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role">—</span></div>
|
||||
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model">—</span></div>
|
||||
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway">—</span></div>
|
||||
</div>
|
||||
<div class="node-info-col">
|
||||
<div><strong>Altitude (above sea level)</strong><strong>: </strong> <span id="info-altitude">—</span></div>
|
||||
</section>
|
||||
<section class="node-info-col" aria-labelledby="node-info-heading-3">
|
||||
<h2 id="node-info-heading-3">Activity</h2>
|
||||
<div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update">—</span></div>
|
||||
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update">—</span></div>
|
||||
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware">—</span></div>
|
||||
<div>
|
||||
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
|
||||
<span id="info-stats"
|
||||
@@ -82,7 +97,7 @@
|
||||
data-label-sent="Packets sent"
|
||||
data-label-seen="Times seen">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Map. -->
|
||||
@@ -150,7 +165,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Packet Filters -->
|
||||
<div class="filter-container" style="margin-bottom:10px; display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<div id="node-packets" class="filter-container" style="scroll-margin-top:20px; margin-bottom:10px; display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<select id="packet_since" aria-label="Packet time range">
|
||||
<option value="">All time</option>
|
||||
<option value="3600">Last hour</option>
|
||||
@@ -420,9 +435,11 @@ async function loadNodeInfo(){
|
||||
document.getElementById("info-channel").textContent = node.channel ?? "—";
|
||||
|
||||
document.getElementById("info-lat").textContent =
|
||||
node.last_lat ? (node.last_lat / 1e7).toFixed(6) : "—";
|
||||
node.last_lat != null ? (node.last_lat / 1e7).toFixed(6) : "—";
|
||||
document.getElementById("info-lon").textContent =
|
||||
node.last_long ? (node.last_long / 1e7).toFixed(6) : "—";
|
||||
node.last_long != null ? (node.last_long / 1e7).toFixed(6) : "—";
|
||||
document.getElementById("info-altitude").textContent =
|
||||
node.last_altitude != null ? `${node.last_altitude} m` : "—";
|
||||
const coverageBtn = document.getElementById("toggleCoverageBtn");
|
||||
const coverageHelp = document.getElementById("coverageHelpLink");
|
||||
if (coverageBtn) {
|
||||
@@ -495,6 +512,10 @@ function initPacketPortFilter() {
|
||||
opt.textContent = `${PORT_LABEL_MAP[p]} (${p})`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
const requestedPort = new URLSearchParams(window.location.search).get("portnum");
|
||||
if ([...sel.options].some(option => option.value === requestedPort)) {
|
||||
sel.value = requestedPort;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1182,7 +1203,15 @@ async function loadNeighborTimeSeries() {
|
||||
grid: { bottom: 60 },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "line" }
|
||||
axisPointer: { type: "line" },
|
||||
formatter: params => {
|
||||
if (!params.length) return "";
|
||||
const timestamp = Number(params[0].axisValue);
|
||||
const heading = new Date(timestamp / 1000).toLocaleString();
|
||||
return escapeHtml(heading) + "<br>" + params.map(point =>
|
||||
`${point.marker}${escapeHtml(point.seriesName)}: ${escapeHtml(point.value ?? "N/A")}`
|
||||
).join("<br>");
|
||||
}
|
||||
},
|
||||
legend: {
|
||||
type: "scroll",
|
||||
@@ -1370,7 +1399,7 @@ function exportCSV(type){
|
||||
}
|
||||
}
|
||||
|
||||
const csv = rows.map(r => r.join(",")).join("\n");
|
||||
const csv = csvFromRows(rows);
|
||||
const blob = new Blob([csv], { type:"text/csv" });
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
@@ -1415,8 +1444,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
await drawNeighbors(fromNodeId, neighborIds);
|
||||
}
|
||||
|
||||
await reloadPackets();
|
||||
initPacketPortFilter();
|
||||
await reloadPackets();
|
||||
await loadTelemetryCharts();
|
||||
await loadNeighborTimeSeries();
|
||||
await loadPacketHistogram();
|
||||
@@ -1506,10 +1535,8 @@ async function loadNodeStats(nodeId) {
|
||||
const portName = PORT_LABEL_MAP[pkt.portnum] || `Port ${pkt.portnum}`;
|
||||
const displayPacketId = pkt.packet_id || (pkt.portnum === 73 ? "Not a Packet" : "");
|
||||
|
||||
// Escape quotes + line breaks for CSV safety
|
||||
const payload = (pkt.payload || "")
|
||||
.replace(/"/g, '""')
|
||||
.replace(/\r?\n/g, " ");
|
||||
// Keep each packet on one line; csvFromRows() does the quoting.
|
||||
const payload = (pkt.payload || "").replace(/\r?\n/g, " ");
|
||||
|
||||
rows.push([
|
||||
time,
|
||||
@@ -1518,11 +1545,11 @@ async function loadNodeStats(nodeId) {
|
||||
pkt.to_node_id,
|
||||
pkt.portnum,
|
||||
portName,
|
||||
`"${payload}"`
|
||||
payload
|
||||
]);
|
||||
}
|
||||
|
||||
const csv = rows.map(r => r.join(",")).join("\n");
|
||||
const csv = csvFromRows(rows);
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
|
||||
const link = document.createElement("a");
|
||||
@@ -1543,8 +1570,16 @@ async function loadNodeQr() {
|
||||
try {
|
||||
const qrRes = await fetch(`/api/node/${fromNodeId}/qr`);
|
||||
const qrData = await qrRes.json();
|
||||
if (qrRes.ok && qrData.meshtastic_url) {
|
||||
currentMeshtasticUrl = qrData.meshtastic_url;
|
||||
if (qrRes.ok) {
|
||||
// No URL means no usable public key: none is known, or the node reported several.
|
||||
currentMeshtasticUrl = qrData.meshtastic_url || "";
|
||||
const canShare = Boolean(currentMeshtasticUrl);
|
||||
const keyConflict = Boolean(qrData.public_key_conflict);
|
||||
document.getElementById("copyUrlBtn").style.display = canShare ? "" : "none";
|
||||
document.getElementById("showQrBtn").style.display = canShare ? "" : "none";
|
||||
document.getElementById("qrUnavailable").style.display =
|
||||
canShare || keyConflict ? "none" : "";
|
||||
document.getElementById("qrKeyConflict").style.display = keyConflict ? "" : "none";
|
||||
actionsDiv.style.display = "flex";
|
||||
} else {
|
||||
actionsDiv.style.display = "none";
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
.favorite-star { cursor:pointer; font-size:20px; color:#8b98aa; user-select:none; }
|
||||
.favorite-star.active { color:#ad7800; }
|
||||
.mesh-data-page .favorites-btn.active { background:#fff3d6; border-color:#eacb80; color:#8b6200; }
|
||||
.node-pagination { display:flex; justify-content:flex-end; align-items:center; gap:12px; margin-top:14px; flex-wrap:wrap; }
|
||||
.sort-icon { font-size:10px; }
|
||||
#mobile-node-list { display:none; }
|
||||
.node-card { background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; padding:16px; margin-bottom:12px; font-size:13px; overflow-wrap:anywhere; }
|
||||
@@ -56,7 +57,7 @@
|
||||
<option value="" data-translate-lang="all_firmware">All Firmware</option>
|
||||
</select>
|
||||
|
||||
<button class="favorites-btn" id="favorites-btn" data-translate-lang="show_favorites">
|
||||
<button type="button" class="favorites-btn" id="favorites-btn" aria-pressed="false" data-translate-lang="show_favorites">
|
||||
⭐ Show Favorites
|
||||
</button>
|
||||
|
||||
@@ -115,6 +116,11 @@
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div id="mobile-node-list" style="display:none;"></div>
|
||||
<nav class="node-pagination" aria-label="Node list pages">
|
||||
<span id="node-page-range" class="reach-muted" aria-live="polite"></span>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="node-page-previous" disabled>Previous</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" id="node-page-next" disabled>Next</button>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
// =====================================================
|
||||
@@ -226,7 +232,7 @@ function timeAgoFromMs(msTimestamp) {
|
||||
// =====================================================
|
||||
document.addEventListener("DOMContentLoaded", async function() {
|
||||
|
||||
await loadTranslationsNodelist();
|
||||
const translationsReady = loadTranslationsNodelist();
|
||||
loadFavorites();
|
||||
|
||||
const tbody = document.getElementById("node-table-body");
|
||||
@@ -244,6 +250,12 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
const clearBtn = document.getElementById("clear-btn");
|
||||
const favoritesBtn = document.getElementById("favorites-btn");
|
||||
|
||||
const pageSize = 100;
|
||||
let currentPage = 1;
|
||||
let filteredNodes = [];
|
||||
const previousPage = document.getElementById("node-page-previous");
|
||||
const nextPage = document.getElementById("node-page-next");
|
||||
|
||||
let lastIsMobile = (window.innerWidth <= 768);
|
||||
|
||||
async function loadNodesForActiveDays() {
|
||||
@@ -254,6 +266,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
if (!res.ok) throw new Error("Failed to fetch nodes");
|
||||
|
||||
const data = await res.json();
|
||||
await translationsReady;
|
||||
|
||||
allNodes = data.nodes.map(n => {
|
||||
const firmware = n.firmware || n.firmware_version || "";
|
||||
@@ -278,7 +291,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
});
|
||||
|
||||
populateFilters(allNodes);
|
||||
applyFilters(); // ensures initial sort + render uses same path
|
||||
await applyFilters(); // ensures initial sort + render uses same path
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
}
|
||||
@@ -294,6 +307,15 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
previousPage.addEventListener("click", () => {
|
||||
currentPage--;
|
||||
applyFilters(false);
|
||||
});
|
||||
nextPage.addEventListener("click", () => {
|
||||
currentPage++;
|
||||
applyFilters(false);
|
||||
});
|
||||
|
||||
roleFilter.addEventListener("change", applyFilters);
|
||||
channelFilter.addEventListener("change", applyFilters);
|
||||
hwFilter.addEventListener("change", applyFilters);
|
||||
@@ -390,15 +412,19 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
}
|
||||
|
||||
function toggleFavoritesFilter() {
|
||||
showOnlyFavorites = !showOnlyFavorites;
|
||||
favoritesBtn.textContent = showOnlyFavorites
|
||||
? "Show All"
|
||||
: "⭐ Show Favorites";
|
||||
favoritesBtn.classList.toggle("active", showOnlyFavorites);
|
||||
if (showOnlyFavorites) {
|
||||
clearFilters();
|
||||
return;
|
||||
}
|
||||
showOnlyFavorites = true;
|
||||
favoritesBtn.textContent = "Show All";
|
||||
favoritesBtn.classList.add("active");
|
||||
favoritesBtn.setAttribute("aria-pressed", "true");
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
async function applyFilters() {
|
||||
async function applyFilters(resetPage = true) {
|
||||
if (resetPage !== false) currentPage = 1;
|
||||
setStatus("Updating…");
|
||||
await nextFrame();
|
||||
const searchTerm = searchBox.value.trim().toLowerCase();
|
||||
@@ -418,12 +444,21 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
|
||||
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
||||
|
||||
filteredNodes = filtered;
|
||||
renderTable(filtered);
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
function renderTable(nodes) {
|
||||
const total = nodes.length;
|
||||
currentPage = Math.max(1, Math.min(currentPage, Math.ceil(total / pageSize)));
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
document.getElementById("node-page-range").textContent = total
|
||||
? `${start + 1}–${Math.min(start + pageSize, total)} of ${total} nodes` : "0 nodes";
|
||||
previousPage.disabled = currentPage === 1;
|
||||
nextPage.disabled = start + pageSize >= total;
|
||||
nodes = nodes.slice(start, start + pageSize);
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const shouldRenderTable = !isMobile;
|
||||
|
||||
@@ -513,7 +548,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
// Toggle correct view
|
||||
mobileList.style.display = isMobile ? "block" : "none";
|
||||
|
||||
countSpan.textContent = nodes.length;
|
||||
countSpan.textContent = total;
|
||||
|
||||
if (shouldRenderTable) {
|
||||
tbody.appendChild(tableFrag);
|
||||
@@ -533,31 +568,33 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
sortAsc = true;
|
||||
showOnlyFavorites = false;
|
||||
|
||||
favoritesBtn.textContent = "⭐ Show Favorites";
|
||||
favoritesBtn.textContent = nodelistTranslations.show_favorites || "⭐ Show Favorites";
|
||||
favoritesBtn.classList.remove("active");
|
||||
favoritesBtn.setAttribute("aria-pressed", "false");
|
||||
|
||||
applyFilters();
|
||||
updateSortIcons();
|
||||
}
|
||||
|
||||
function exportToCSV() {
|
||||
const rows = [];
|
||||
const headerList = Array.from(headers).map(h =>
|
||||
`"${h.innerText.replace(/▲|▼/g,'')}"`
|
||||
);
|
||||
rows.push(headerList.join(","));
|
||||
|
||||
const trs = tbody.querySelectorAll("tr");
|
||||
trs.forEach(tr => {
|
||||
const cells = Array.from(tr.children).map(td =>
|
||||
`"${td.innerText.replace(/"/g,'""')}"`
|
||||
);
|
||||
rows.push(cells.join(","));
|
||||
const rows = [Array.from(headers).map(h => h.innerText.replace(/▲|▼/g,''))];
|
||||
filteredNodes.forEach(node => {
|
||||
rows.push([
|
||||
node.short_name || "N/A", node.long_name || "N/A", node.hw_model || "N/A",
|
||||
node.firmware || "N/A", node.role || "N/A",
|
||||
node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A",
|
||||
node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A",
|
||||
node.channel || "N/A",
|
||||
node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No"),
|
||||
timeAgoFromMs(node.last_seen_ms), isFavorite(node.node_id) ? "★" : "☆"
|
||||
]);
|
||||
});
|
||||
|
||||
const csv = "data:text/csv;charset=utf-8,\uFEFF" + rows.join("\n");
|
||||
// A Blob, not a data: URL: encodeURI() leaves "#" alone, which cut the file short at
|
||||
// the first "#" in any cell (node names such as "Big Rock Ridge #1").
|
||||
const blob = new Blob(["\uFEFF" + csvFromRows(rows)], { type: "text/csv;charset=utf-8" });
|
||||
const a = document.createElement("a");
|
||||
a.href = encodeURI(csv);
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = "nodelist.csv";
|
||||
a.click();
|
||||
}
|
||||
|
||||
@@ -211,7 +211,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
${
|
||||
telemetryExtras.length
|
||||
? `<dt data-translate-lang="decoded_telemetry">${packetTranslations.decoded_telemetry || "Decoded Telemetry"}</dt>
|
||||
<dd><pre>${telemetryExtras.join("\n")}</pre></dd>`
|
||||
<dd><pre>${escapeHtml(telemetryExtras.join("\n"))}</pre></dd>`
|
||||
: ""
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<section class="reach-page">
|
||||
<header class="data-heading"><div><span class="mesh-eyebrow">GATEWAY DIAGNOSTICS</span><h1>Reliability</h1><p>See how consistently gateways hear packets from a selected node.</p></div><a class="mesh-secondary-button" href="/nodelist">All nodes ↗</a></header>
|
||||
<details class="reach-description"><summary>How reliability is calculated</summary><p>Review how often each gateway hears packets from a selected node. The report uses the
|
||||
latest selected packet sample and calculates reliability as heard packets divided by packets
|
||||
latest selected packet sample on the node’s recorded main channel and calculates reliability as heard packets divided by packets
|
||||
reviewed. The range table groups gateways by reliability bands, while the gateway list
|
||||
shows each gateway's heard count and percentage for the same sample. Reliability is
|
||||
calculated only from Text, Position, Node Info, and Telemetry packets; other packet types
|
||||
@@ -94,6 +94,7 @@
|
||||
</a>
|
||||
</form>
|
||||
|
||||
<p class="reach-muted" id="reach-channel" role="status"></p>
|
||||
<div class="reach-summary">
|
||||
<div class="reach-metric">
|
||||
<div class="text-secondary">Latest Packets</div>
|
||||
@@ -466,7 +467,19 @@ async function loadReachReport(nodeId = reachNodeId) {
|
||||
resetReliabilityMap();
|
||||
|
||||
try {
|
||||
const nodesById = new Map(reachNodes.map(node => [Number(node.node_id), node]));
|
||||
const mainChannel = nodesById.get(reachNodeId)?.channel;
|
||||
document.getElementById("reach-channel").textContent = mainChannel
|
||||
? `Main channel: ${mainChannel}` : "Main channel unknown — no report available yet.";
|
||||
if (!mainChannel) {
|
||||
packetCountEl.textContent = "0";
|
||||
gatewayCountEl.textContent = "0";
|
||||
bucketBodyEl.innerHTML = '<tr><td colspan="2" class="reach-muted">The node’s main channel is unknown.</td></tr>';
|
||||
setGatewayTables('<tr><td colspan="4" class="reach-muted">The node’s main channel is unknown.</td></tr>');
|
||||
return;
|
||||
}
|
||||
const packetUrl = new URL("/api/packets", window.location.origin);
|
||||
packetUrl.searchParams.set("channel", mainChannel);
|
||||
packetUrl.searchParams.set("from_node_id", reachNodeId);
|
||||
packetUrl.searchParams.set("limit", packetLimit);
|
||||
if (packetType) packetUrl.searchParams.set("portnum", packetType);
|
||||
@@ -475,8 +488,7 @@ async function loadReachReport(nodeId = reachNodeId) {
|
||||
fetchJson(packetUrl),
|
||||
]);
|
||||
|
||||
const packets = packetData.packets || [];
|
||||
const nodesById = new Map(reachNodes.map(node => [Number(node.node_id), node]));
|
||||
const packets = (packetData.packets || []).filter(packet => packet.channel === mainChannel);
|
||||
|
||||
packetCountEl.textContent = packets.length;
|
||||
|
||||
|
||||
+200
-15
@@ -15,9 +15,10 @@
|
||||
.summary-card:nth-child(4) .summary-count { color:#497b55; }
|
||||
.stats-chart-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; }
|
||||
.card-section { min-width:0; display:flex; align-items:center; align-content:flex-start; flex-wrap:wrap; gap:10px; padding:20px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; }
|
||||
.section-header { width:100%; font-size:15px; font-weight:600; color:var(--mesh-text); padding-bottom:14px; margin:0 0 2px; border-bottom:1px solid var(--mesh-border); }
|
||||
.section-header { text-transform:lowercase; width:100%; font-size:15px; font-weight:600; color:var(--mesh-text); padding-bottom:14px; margin:0 0 2px; border-bottom:1px solid var(--mesh-border); }
|
||||
.total-count { font-size:12px; color:var(--mesh-muted); margin-right:auto; }
|
||||
.chart { width:100%; min-width:0; height:340px; margin-top:8px; }
|
||||
#chart_hourly_all,#chart_portnum_1 { height:380px; }
|
||||
#chartModal { display:none; position:fixed; inset:0; background:#17263c99; z-index:9999; justify-content:center; align-items:center; padding:16px; }
|
||||
#chartModal > div { position:relative; width:95%; max-width:1200px; height:85%; background:var(--mesh-surface); border-radius:10px; padding:48px 16px 16px; display:flex; flex-direction:column; }
|
||||
#chartModal #closeModal { position:absolute; top:10px; right:12px; font-size:22px; padding:0; width:32px; height:32px; line-height:1; }
|
||||
@@ -91,6 +92,26 @@
|
||||
<div id="chart_daily_snapshot_nodes_gateways" class="chart"></div>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header">Top text senders · main channel</p>
|
||||
<button id="text-nodes-refresh" type="button">Refresh sample</button>
|
||||
<span class="total-count" id="text-nodes-status" role="status">Loading…</span>
|
||||
<button class="expand-btn" data-chart="chart_text_nodes">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_text_nodes">Export CSV</button>
|
||||
<div id="chart_text_nodes" class="chart"></div>
|
||||
<p class="total-count">Top 10 senders from the latest 500 text packets across all channels. Only packets on the sender’s recorded main channel count; unknown channels are excluded.</p>
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header">10 highest routers · altitude</p>
|
||||
<button id="altitude-refresh" type="button">Refresh report</button>
|
||||
<span class="total-count" id="altitude-status" role="status">Loading…</span>
|
||||
<button class="expand-btn" data-chart="chart_altitude">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_altitude">Export CSV</button>
|
||||
<div id="chart_altitude" class="chart"></div>
|
||||
<p class="total-count">Latest reported altitude in meters above sea level. Only ROUTER, ROUTER_CLIENT, and ROUTER_LATE nodes with known altitude are included. Click a bar to view the node.</p>
|
||||
</div>
|
||||
|
||||
<!-- Packet Types Pie Chart with Channel Selector -->
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="packet_types_last_24h">
|
||||
@@ -192,6 +213,139 @@
|
||||
<script>
|
||||
const PORTNUM_LABELS = window.PORT_LABEL_MAP;
|
||||
|
||||
let statsNodesPromise;
|
||||
let textNodeRows = [];
|
||||
function rankMainChannelTextSenders(packets, nodes) {
|
||||
const byId = new Map(nodes.map(node => [String(node.node_id), node]));
|
||||
const counts = new Map();
|
||||
const seen = new Set();
|
||||
for (const packet of packets) {
|
||||
if (Number(packet.portnum) !== 1) continue;
|
||||
if (packet.id != null && seen.has(String(packet.id))) continue;
|
||||
if (packet.id != null) seen.add(String(packet.id));
|
||||
const node = byId.get(String(packet.from_node_id));
|
||||
if (!node?.channel || packet.channel !== node.channel) continue;
|
||||
const id = String(node.node_id);
|
||||
if (!counts.has(id)) counts.set(id, {...node, packet_count: 0});
|
||||
counts.get(id).packet_count++;
|
||||
}
|
||||
return [...counts.values()].sort((a, b) =>
|
||||
b.packet_count - a.packet_count || Number(a.node_id) - Number(b.node_id)
|
||||
);
|
||||
}
|
||||
function openTextSender(params) {
|
||||
if (params.componentType !== "series" || params.seriesType !== "bar") return;
|
||||
const node = textNodeRows[params.dataIndex];
|
||||
if (node) window.location.assign(`/node/${encodeURIComponent(node.node_id)}?portnum=1#node-packets`);
|
||||
}
|
||||
let textNodesRequest = 0;
|
||||
async function loadTextNodes() {
|
||||
const request = ++textNodesRequest;
|
||||
const status = document.getElementById("text-nodes-status");
|
||||
const chart = initMeshChart(document.getElementById("chart_text_nodes"));
|
||||
status.textContent = "Loading…";
|
||||
textNodeRows = [];
|
||||
chart.clear();
|
||||
chart.off("click");
|
||||
chart.on("click", openTextSender);
|
||||
try {
|
||||
const [response, nodes] = await Promise.all([
|
||||
fetch("/api/packets?portnum=1&limit=500"),
|
||||
fetchStatsNodes()
|
||||
]);
|
||||
if (!response.ok) throw new Error("Unable to load text packets");
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.packets)) throw new Error("Invalid packet response");
|
||||
if (request !== textNodesRequest) return;
|
||||
const sample = data.packets.slice(0, 500);
|
||||
const ranked = rankMainChannelTextSenders(sample, nodes);
|
||||
textNodeRows = ranked.slice(0, 10);
|
||||
const matched = ranked.reduce((sum, node) => sum + node.packet_count, 0);
|
||||
status.textContent = matched
|
||||
? `${matched} of ${sample.length} sampled packets match main channels`
|
||||
: `No main-channel matches in ${sample.length} sampled packets`;
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: {type: "shadow"},
|
||||
formatter: params => {
|
||||
const row = textNodeRows[params[0]?.dataIndex];
|
||||
if (!row) return "";
|
||||
return `${escapeHtml(row.long_name || row.short_name || row.node_id)}<br>` +
|
||||
`Channel: ${escapeHtml(row.channel)}<br>Text packets: ${row.packet_count.toLocaleString()}<br>Click to view text packets`;
|
||||
}
|
||||
},
|
||||
grid: {left: 10, right: 48, top: 12, bottom: 28, containLabel: true},
|
||||
xAxis: {type: "value", minInterval: 1},
|
||||
yAxis: {
|
||||
type: "category", inverse: true,
|
||||
data: textNodeRows.map(row => row.short_name || row.long_name || String(row.node_id)),
|
||||
axisLabel: {width: 90, overflow: "truncate"}
|
||||
},
|
||||
series: [{name: "Text packets", type: "bar", barMaxWidth: 24,
|
||||
itemStyle: {color: "#2b9961", borderRadius: [0, 4, 4, 0]},
|
||||
label: {show: true, position: "right", color: "#64748b"},
|
||||
data: textNodeRows.map(row => row.packet_count)}]
|
||||
});
|
||||
} catch (error) {
|
||||
if (request !== textNodesRequest) return;
|
||||
status.textContent = "Unable to load text senders. Use Refresh sample to retry.";
|
||||
console.error("Top text senders:", error);
|
||||
}
|
||||
}
|
||||
document.getElementById("text-nodes-refresh").addEventListener("click", loadTextNodes);
|
||||
loadTextNodes();
|
||||
|
||||
let altitudeRows = [];
|
||||
function rankAltitudeNodes(nodes) {
|
||||
return nodes
|
||||
.filter(node => Number.isFinite(node.last_altitude) && node.node_id != null
|
||||
&& ["ROUTER", "ROUTER_CLIENT", "ROUTER_LATE"].includes((node.role || "").toUpperCase()))
|
||||
.sort((a, b) => b.last_altitude - a.last_altitude || a.node_id - b.node_id)
|
||||
.slice(0, 10);
|
||||
}
|
||||
function openAltitudeNode(event) {
|
||||
const node = altitudeRows[event.dataIndex];
|
||||
if (event.componentType === "series" && node) {
|
||||
location.href = `/node/${encodeURIComponent(node.node_id)}`;
|
||||
}
|
||||
}
|
||||
async function loadAltitudeNodes(refreshNodes = false) {
|
||||
const status = document.getElementById("altitude-status");
|
||||
const refresh = document.getElementById("altitude-refresh");
|
||||
const el = document.getElementById("chart_altitude");
|
||||
const chart = echarts.getInstanceByDom(el) || initMeshChart(el);
|
||||
refresh.disabled = true;
|
||||
status.textContent = "Loading…";
|
||||
chart.off("click");
|
||||
chart.on("click", openAltitudeNode);
|
||||
try {
|
||||
altitudeRows = rankAltitudeNodes(await fetchStatsNodes(refreshNodes));
|
||||
chart.setOption({
|
||||
tooltip: {trigger:"axis", renderMode:"richText", valueFormatter:value => `${value} m`},
|
||||
grid: {left:10, right:20, top:55, bottom:10, containLabel:true},
|
||||
xAxis: {type:"category", data:altitudeRows.map(node => node.long_name || node.short_name || node.id),
|
||||
axisLabel:{interval:0, rotate:35, width:85, overflow:"truncate"}},
|
||||
yAxis: {type:"value", name:"Altitude (m above sea level)", nameGap:30},
|
||||
series: [{type:"bar", name:"Altitude", barMaxWidth:45,
|
||||
itemStyle:{color:"#187346"}, label:{show:true, position:"top", formatter:"{c} m"},
|
||||
data:altitudeRows.map(node => node.last_altitude)}]
|
||||
}, true);
|
||||
status.textContent = altitudeRows.length
|
||||
? `Showing ${altitudeRows.length} nodes · highest first`
|
||||
: "No routers have reported altitude yet.";
|
||||
} catch (error) {
|
||||
altitudeRows = [];
|
||||
chart.clear();
|
||||
status.textContent = "Unable to load altitudes. Use Refresh report to retry.";
|
||||
console.error(error);
|
||||
} finally {
|
||||
refresh.disabled = false;
|
||||
}
|
||||
}
|
||||
document.getElementById("altitude-refresh").addEventListener("click", () => loadAltitudeNodes(true));
|
||||
loadAltitudeNodes();
|
||||
|
||||
// --- Fetch & Processing ---
|
||||
async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||
try{
|
||||
@@ -214,14 +368,24 @@ async function fetchDailySnapshots(){
|
||||
}catch{return [];}
|
||||
}
|
||||
|
||||
async function fetchNodes(){
|
||||
try{
|
||||
const res=await fetch("/api/nodes");
|
||||
const json=await res.json();
|
||||
return json.nodes||[];
|
||||
}catch{
|
||||
return [];
|
||||
function fetchStatsNodes(refresh = false) {
|
||||
if (refresh) statsNodesPromise = null;
|
||||
if (!statsNodesPromise) {
|
||||
statsNodesPromise = fetch("/api/nodes").then(async response => {
|
||||
if (!response.ok) throw new Error("Unable to load nodes");
|
||||
const data = await response.json();
|
||||
if (!Array.isArray(data.nodes)) throw new Error("Invalid node response");
|
||||
return data.nodes;
|
||||
}).catch(error => {
|
||||
statsNodesPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return statsNodesPromise;
|
||||
}
|
||||
async function fetchNodes(){
|
||||
try { return await fetchStatsNodes(); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
async function fetchChannels(){
|
||||
@@ -247,12 +411,12 @@ function buildPacketTypeBreakdownFromSeries(allData, perPortSeries){
|
||||
}
|
||||
|
||||
function processCountField(nodes,field){
|
||||
const counts={};
|
||||
const counts=new Map(); // keys are mesh-provided strings, e.g. a channel called "constructor"
|
||||
nodes.forEach(n=>{
|
||||
const key=n[field]||"Unknown";
|
||||
counts[key]=(counts[key]||0)+1;
|
||||
counts.set(key,(counts.get(key)||0)+1);
|
||||
});
|
||||
return Object.entries(counts).map(([name,value])=>({name,value}));
|
||||
return Array.from(counts,([name,value])=>({name,value}));
|
||||
}
|
||||
|
||||
function updateTotalCount(domId,data){
|
||||
@@ -295,15 +459,20 @@ function renderChart(domId,data,type,color){
|
||||
const chart=initMeshChart(el);
|
||||
const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():'');
|
||||
const counts=data.map(d=>d.count??d.packet_count??0);
|
||||
const hourly = domId === 'chart_hourly_all' || domId === 'chart_portnum_1';
|
||||
chart.setOption({
|
||||
backgroundColor:'#ffffff',
|
||||
tooltip:{trigger:'axis'},
|
||||
grid:{left:'6%', right:'6%', bottom:'18%'},
|
||||
grid:hourly
|
||||
? {left:15, right:20, top:35, bottom:20, containLabel:true}
|
||||
: {left:'6%', right:'6%', bottom:'18%'},
|
||||
xAxis:{
|
||||
type:'category',
|
||||
data:periods,
|
||||
axisLine:{lineStyle:{color:'#c3cfdf'}},
|
||||
axisLabel:{rotate:45,color:'#64748b'}
|
||||
axisLabel:hourly
|
||||
? {rotate:45, color:'#64748b', margin:16, hideOverlap:true}
|
||||
: {rotate:45,color:'#64748b'}
|
||||
},
|
||||
yAxis:{
|
||||
type:'value',
|
||||
@@ -330,7 +499,7 @@ function renderPieChart(elId,data,name){
|
||||
backgroundColor:"#ffffff",
|
||||
tooltip:{
|
||||
trigger:"item",
|
||||
formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`
|
||||
formatter: params=>`${escapeHtml(params.name)}: ${Math.round(params.percent)}% (${params.value})`
|
||||
},
|
||||
series:[{
|
||||
name:name,
|
||||
@@ -622,6 +791,9 @@ document.querySelectorAll(".expand-btn").forEach(btn=>{
|
||||
modal.style.display="flex";
|
||||
modalChart=initMeshChart(modalChartEl);
|
||||
modalChart.setOption(sourceChart.getOption());
|
||||
modalChart.off("click");
|
||||
if (chartId === "chart_text_nodes") modalChart.on("click", openTextSender);
|
||||
if (chartId === "chart_altitude") modalChart.on("click", openAltitudeNode);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -632,7 +804,7 @@ document.getElementById("closeModal").addEventListener("click",()=>{
|
||||
});
|
||||
|
||||
function downloadCSV(filename,rows){
|
||||
const csvContent=rows.map(r=>r.map(v=>`"${v}"`).join(",")).join("\n");
|
||||
const csvContent=csvFromRows(rows);
|
||||
const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
|
||||
const link=document.createElement("a");
|
||||
link.href=URL.createObjectURL(blob);
|
||||
@@ -649,6 +821,19 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
|
||||
if(!chart)return;
|
||||
const option=chart.getOption();
|
||||
let rows=[];
|
||||
if (chartId === "chart_altitude") {
|
||||
rows = [["Node ID", "Short Name", "Long Name", "Altitude (m above sea level)"],
|
||||
...altitudeRows.map(node => [node.node_id, node.short_name || "", node.long_name || "", node.last_altitude])];
|
||||
downloadCSV(chartId + ".csv", rows);
|
||||
return;
|
||||
}
|
||||
if (chartId === "chart_text_nodes") {
|
||||
rows = [["Node ID", "Short Name", "Long Name", "Main Channel", "Text Packets"],
|
||||
...textNodeRows.map(row => [row.node_id, row.short_name || "", row.long_name || "", row.channel, row.packet_count])];
|
||||
downloadCSV(chartId + ".csv", rows);
|
||||
return;
|
||||
}
|
||||
if (!option.series?.length) return;
|
||||
if(option.series[0].type==="bar"||option.series[0].type==="line"){
|
||||
const xData=option.xAxis[0].data;
|
||||
const series = option.series || [];
|
||||
|
||||
@@ -47,6 +47,10 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
.mesh-theme-toggle { flex-shrink:0; display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px; border:1px solid #ffffff70; border-radius:6px; background:transparent; color:white; font:22px/1 system-ui,sans-serif; cursor:pointer; }
|
||||
.mesh-header .mesh-theme-toggle { margin-left:auto; }
|
||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
||||
.mesh-theme-toggle .theme-icon { width:22px; height:22px; flex-shrink:0; }
|
||||
.mesh-theme-toggle .theme-icon--sun { display:none; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--sun { display:block; }
|
||||
[data-bs-theme="dark"] .mesh-theme-toggle .theme-icon--moon { display:none; }
|
||||
.mesh-theme-toggle:focus-visible { outline:2px solid white; outline-offset:3px; }
|
||||
@media(max-width:1000px) { .mesh-header .mesh-menu-toggle { margin-left:auto; } .mesh-header .mesh-theme-toggle { margin-left:12px; } .mesh-header #site-menu { order:4; } }
|
||||
[data-bs-theme="dark"] :is(.chat-status,.chat-details,.chat-channel,.chat-status[data-state="paused"],.chat-status[data-state="loading"],.good-x) { background:var(--mesh-soft); color:var(--mesh-primary); border-color:var(--mesh-border); }
|
||||
|
||||
@@ -193,7 +193,7 @@ async function loadTraceroute() {
|
||||
tooltip: {
|
||||
formatter: () => {
|
||||
const longName = nodeLongNameById.get(n.name) || n.name;
|
||||
return longName === n.name ? n.name : `${longName} (${n.name})`;
|
||||
return longName === n.name ? n.name : `${escapeHtml(longName)} (${n.name})`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
+5
-2
@@ -91,7 +91,7 @@ class Packet:
|
||||
text_payload = (
|
||||
"ACK"
|
||||
if payload.error_reason == Routing.Error.NONE
|
||||
else f"NAK: {Routing.Error.Name(payload.error_reason)}"
|
||||
else f"NAK: {decode_payload.enum_name(Routing.Error, payload.error_reason)}"
|
||||
)
|
||||
elif isinstance(payload, Message):
|
||||
text_payload = text_format.MessageToString(payload)
|
||||
@@ -369,7 +369,10 @@ async def traceroute_page(request):
|
||||
# Keep !!
|
||||
@routes.get("/graph/traceroute/{packet_id}")
|
||||
async def graph_traceroute(request):
|
||||
packet_id = int(request.match_info['packet_id'])
|
||||
try:
|
||||
packet_id = api.parse_db_int(request.match_info['packet_id'])
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(text="packet_id must be an integer") from None
|
||||
traceroutes = list(await store.get_traceroute(packet_id))
|
||||
|
||||
packet = await store.get_packet(packet_id)
|
||||
|
||||
+141
-46
@@ -170,6 +170,57 @@ COVERAGE_LIMITS = {
|
||||
COVERAGE_MAX_STEPS = 4_000 # radius_km / step_km
|
||||
|
||||
|
||||
# Longest look-back any stats/channel query may ask for. Larger windows only make the
|
||||
# aggregate scan the whole table, which is what an abusive caller would want.
|
||||
MAX_PERIOD_LENGTH = 3650
|
||||
|
||||
|
||||
def parse_db_int(text, base=10, bits=64):
|
||||
"""int(text, base), but a value that does not fit a signed `bits`-bit column raises
|
||||
ValueError too. Without this a long number parses fine and then fails inside the
|
||||
database driver, which answers 500. Node and packet ids are BIGINT (64 bits); portnum
|
||||
is INTEGER (32 bits on PostgreSQL).
|
||||
"""
|
||||
value = int(text, base)
|
||||
if not -(2 ** (bits - 1)) <= value < 2 ** (bits - 1):
|
||||
raise ValueError(f"outside the {bits}-bit integer range")
|
||||
return value
|
||||
|
||||
|
||||
def _query_int(request, name, default=None, minimum=None, maximum=None):
|
||||
"""Read an integer query parameter, answering 400 instead of 500 on bad input.
|
||||
|
||||
Values outside [minimum, maximum] are clamped rather than rejected so existing
|
||||
callers that ask for "everything" keep working within the allowed bounds.
|
||||
"""
|
||||
raw = request.query.get(name)
|
||||
if raw is None or raw == "":
|
||||
return default
|
||||
try:
|
||||
value = parse_db_int(raw)
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(
|
||||
text=json.dumps({"error": f"{name} must be an integer"}),
|
||||
content_type="application/json",
|
||||
) from None
|
||||
if minimum is not None:
|
||||
value = max(minimum, value)
|
||||
if maximum is not None:
|
||||
value = min(maximum, value)
|
||||
return value
|
||||
|
||||
|
||||
def _match_int(request, name):
|
||||
"""Read an integer path segment, answering 400 instead of 500 on bad input."""
|
||||
try:
|
||||
return parse_db_int(request.match_info[name])
|
||||
except (KeyError, ValueError):
|
||||
raise web.HTTPBadRequest(
|
||||
text=json.dumps({"error": f"{name} must be an integer"}),
|
||||
content_type="application/json",
|
||||
) from None
|
||||
|
||||
|
||||
def init_api_module(packet_class, seq_regex, lang_dir):
|
||||
"""Initialize API module with dependencies from main web module."""
|
||||
global Packet, SEQ_REGEX, LANG_DIR
|
||||
@@ -181,7 +232,7 @@ def init_api_module(packet_class, seq_regex, lang_dir):
|
||||
@routes.get("/api/channels")
|
||||
async def api_channels(request: web.Request):
|
||||
period_type = request.query.get("period_type", "hour")
|
||||
length = int(request.query.get("length", 24))
|
||||
length = _query_int(request, "length", 24, 1, MAX_PERIOD_LENGTH)
|
||||
|
||||
try:
|
||||
channels = await store.get_channels_in_period(period_type, length)
|
||||
@@ -225,6 +276,7 @@ async def api_nodes(request):
|
||||
"role": n.role,
|
||||
"last_lat": getattr(n, "last_lat", None),
|
||||
"last_long": getattr(n, "last_long", None),
|
||||
"last_altitude": n.last_altitude,
|
||||
"channel": n.channel,
|
||||
"is_mqtt_gateway": getattr(n, "is_mqtt_gateway", None),
|
||||
# "last_update": n.last_update.isoformat(),
|
||||
@@ -249,6 +301,7 @@ async def api_packets(request):
|
||||
since_str = request.query.get("since")
|
||||
portnum_str = request.query.get("portnum")
|
||||
contains = request.query.get("contains")
|
||||
channel = request.query.get("channel")
|
||||
|
||||
# NEW — explicit filters
|
||||
from_node_id_str = request.query.get("from_node_id")
|
||||
@@ -258,7 +311,7 @@ async def api_packets(request):
|
||||
# --- If a packet_id is provided, return only that packet ---
|
||||
if packet_id_str:
|
||||
try:
|
||||
packet_id = int(packet_id_str)
|
||||
packet_id = parse_db_int(packet_id_str)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "Invalid packet_id format"}, status=400)
|
||||
|
||||
@@ -291,17 +344,17 @@ async def api_packets(request):
|
||||
since = None
|
||||
if since_str:
|
||||
try:
|
||||
since = int(since_str)
|
||||
since = parse_db_int(since_str)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid 'since' value (expected microseconds): {since_str}")
|
||||
logger.warning("Invalid 'since' value (expected microseconds): %r", since_str)
|
||||
|
||||
# --- Parse portnum ---
|
||||
portnum = None
|
||||
if portnum_str:
|
||||
try:
|
||||
portnum = int(portnum_str)
|
||||
portnum = parse_db_int(portnum_str, bits=32)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid portnum: {portnum_str}")
|
||||
logger.warning("Invalid portnum: %r", portnum_str)
|
||||
|
||||
# --- Parse node filters ---
|
||||
from_node_id = None
|
||||
@@ -310,21 +363,21 @@ async def api_packets(request):
|
||||
|
||||
if from_node_id_str:
|
||||
try:
|
||||
from_node_id = int(from_node_id_str, 0)
|
||||
from_node_id = parse_db_int(from_node_id_str, 0)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid from_node_id: {from_node_id_str}")
|
||||
logger.warning("Invalid from_node_id: %r", from_node_id_str)
|
||||
|
||||
if to_node_id_str:
|
||||
try:
|
||||
to_node_id = int(to_node_id_str, 0)
|
||||
to_node_id = parse_db_int(to_node_id_str, 0)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid to_node_id: {to_node_id_str}")
|
||||
logger.warning("Invalid to_node_id: %r", to_node_id_str)
|
||||
|
||||
if node_id_str:
|
||||
try:
|
||||
node_id = int(node_id_str, 0)
|
||||
node_id = parse_db_int(node_id_str, 0)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid node_id: {node_id_str}")
|
||||
logger.warning("Invalid node_id: %r", node_id_str)
|
||||
|
||||
# --- Fetch packets using explicit filters ---
|
||||
contains_for_query = contains
|
||||
@@ -339,6 +392,7 @@ async def api_packets(request):
|
||||
after=since,
|
||||
contains=contains_for_query,
|
||||
limit=limit,
|
||||
channel=channel,
|
||||
)
|
||||
|
||||
ui_packets = [Packet.from_model(p) for p in packets]
|
||||
@@ -417,16 +471,13 @@ async def api_stats(request):
|
||||
status=400,
|
||||
)
|
||||
|
||||
try:
|
||||
length = int(request.query.get("length", 24))
|
||||
except ValueError:
|
||||
return web.json_response({"error": "length must be an integer"}, status=400)
|
||||
length = _query_int(request, "length", 24, 1, MAX_PERIOD_LENGTH)
|
||||
|
||||
# NEW: optional combined node stats
|
||||
node_str = request.query.get("node")
|
||||
if node_str:
|
||||
try:
|
||||
node_id = int(node_str)
|
||||
node_id = parse_db_int(node_str)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "node must be an integer"}, status=400)
|
||||
|
||||
@@ -457,11 +508,11 @@ async def api_stats(request):
|
||||
# ---- Existing full stats mode (unchanged) ----
|
||||
channel = request.query.get("channel")
|
||||
|
||||
def parse_int_param(name):
|
||||
def parse_int_param(name, bits=64):
|
||||
value = request.query.get(name)
|
||||
if value is not None:
|
||||
try:
|
||||
return int(value)
|
||||
return parse_db_int(value, bits=bits)
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(
|
||||
text=json.dumps({"error": f"{name} must be an integer"}),
|
||||
@@ -469,7 +520,7 @@ async def api_stats(request):
|
||||
) from None
|
||||
return None
|
||||
|
||||
portnum = parse_int_param("portnum")
|
||||
portnum = parse_int_param("portnum", bits=32)
|
||||
to_node = parse_int_param("to_node")
|
||||
from_node = parse_int_param("from_node")
|
||||
|
||||
@@ -499,18 +550,12 @@ async def api_stats_count(request):
|
||||
packet_id = None
|
||||
if packet_id_str:
|
||||
try:
|
||||
packet_id = int(packet_id_str)
|
||||
packet_id = parse_db_int(packet_id_str)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "packet_id must be integer"}, status=400)
|
||||
|
||||
period_type = request.query.get("period_type")
|
||||
length_str = request.query.get("length")
|
||||
length = None
|
||||
if length_str:
|
||||
try:
|
||||
length = int(length_str)
|
||||
except ValueError:
|
||||
return web.json_response({"error": "length must be integer"}, status=400)
|
||||
length = _query_int(request, "length", None, 1, MAX_PERIOD_LENGTH)
|
||||
|
||||
channel = request.query.get("channel")
|
||||
|
||||
@@ -519,7 +564,7 @@ async def api_stats_count(request):
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
return parse_db_int(value)
|
||||
except ValueError:
|
||||
raise web.HTTPBadRequest(
|
||||
text=json.dumps({"error": f"{name} must be integer"}),
|
||||
@@ -670,6 +715,8 @@ async def api_edges(request):
|
||||
_, neighbor_info = decode_payload.decode(packet)
|
||||
except Exception:
|
||||
continue
|
||||
if neighbor_info is None: # payload did not parse as NeighborInfo
|
||||
continue
|
||||
|
||||
for node in neighbor_info.neighbors:
|
||||
edge = (node.node_id, packet.from_node_id)
|
||||
@@ -906,12 +953,19 @@ async def version_endpoint(request):
|
||||
return web.json_response({"error": "Failed to fetch version info"}, status=500)
|
||||
|
||||
|
||||
def finite_or_none(value):
|
||||
"""Map inf/nan to None. json.dumps writes them as Infinity/NaN, which browsers reject."""
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
@routes.get("/api/packets_seen/{packet_id}")
|
||||
async def api_packets_seen(request):
|
||||
try:
|
||||
# --- Validate packet_id ---
|
||||
try:
|
||||
packet_id = int(request.match_info["packet_id"])
|
||||
packet_id = parse_db_int(request.match_info["packet_id"])
|
||||
except (KeyError, ValueError):
|
||||
return web.json_response(
|
||||
{"error": "Invalid or missing packet_id"},
|
||||
@@ -931,7 +985,7 @@ async def api_packets_seen(request):
|
||||
"hop_limit": row.hop_limit,
|
||||
"hop_start": row.hop_start,
|
||||
"channel": row.channel,
|
||||
"rx_snr": row.rx_snr,
|
||||
"rx_snr": finite_or_none(row.rx_snr),
|
||||
"rx_rssi": row.rx_rssi,
|
||||
"topic": row.topic,
|
||||
"import_time_us": row.import_time_us,
|
||||
@@ -950,7 +1004,7 @@ async def api_packets_seen(request):
|
||||
|
||||
@routes.get("/api/traceroute/{packet_id}")
|
||||
async def api_traceroute(request):
|
||||
packet_id = int(request.match_info['packet_id'])
|
||||
packet_id = _match_int(request, "packet_id")
|
||||
|
||||
traceroutes = list(await store.get_traceroute(packet_id))
|
||||
packet = await store.get_packet(packet_id)
|
||||
@@ -1075,11 +1129,12 @@ async def api_stats_top(request):
|
||||
"""
|
||||
|
||||
period_type = request.query.get("period_type", "day")
|
||||
length = int(request.query.get("length", 1))
|
||||
length = _query_int(request, "length", 1, 1, MAX_PERIOD_LENGTH)
|
||||
channel = request.query.get("channel")
|
||||
|
||||
limit = min(int(request.query.get("limit", 20)), 100)
|
||||
offset = int(request.query.get("offset", 0))
|
||||
# SQLite reads a negative LIMIT as "no limit", so the lower bound matters too.
|
||||
limit = _query_int(request, "limit", 20, 1, 100)
|
||||
offset = _query_int(request, "offset", 0, 0, None)
|
||||
|
||||
multiplier = 3600 if period_type == "hour" else 86400
|
||||
window_us = length * multiplier * 1_000_000
|
||||
@@ -1163,7 +1218,7 @@ async def api_node_qr(request):
|
||||
"""
|
||||
try:
|
||||
node_id_str = request.match_info["node_id"]
|
||||
node_id = int(node_id_str, 0)
|
||||
node_id = parse_db_int(node_id_str, 0)
|
||||
except (KeyError, ValueError):
|
||||
return web.json_response({"error": "Invalid node_id"}, status=400)
|
||||
|
||||
@@ -1172,9 +1227,11 @@ async def api_node_qr(request):
|
||||
return web.json_response({"error": "Node not found"}, status=404)
|
||||
|
||||
try:
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from meshtastic.protobuf.admin_pb2 import SharedContact
|
||||
from meshtastic.protobuf.config_pb2 import Config
|
||||
from meshtastic.protobuf.mesh_pb2 import User
|
||||
from meshtastic.protobuf.mesh_pb2 import MeshPacket, User
|
||||
|
||||
user = User()
|
||||
user.id = f"!{node_id:08x}"
|
||||
@@ -1199,18 +1256,52 @@ async def api_node_qr(request):
|
||||
except (AttributeError, TypeError):
|
||||
pass
|
||||
|
||||
contact = SharedContact()
|
||||
contact.node_num = node_id
|
||||
contact.user.CopyFrom(user)
|
||||
contact.manually_verified = False
|
||||
# Meshview does not store public keys, so read them from the node's NODEINFO packets.
|
||||
latest_nodeinfo = None
|
||||
public_keys = set()
|
||||
for payload in await store.get_nodeinfo_payloads(node_id):
|
||||
if not payload:
|
||||
continue
|
||||
try:
|
||||
mesh_packet = MeshPacket.FromString(payload)
|
||||
except DecodeError:
|
||||
continue
|
||||
nodeinfo = decode_payload.decode_payload(
|
||||
PortNum.NODEINFO_APP, mesh_packet.decoded.payload
|
||||
)
|
||||
if not isinstance(nodeinfo, User):
|
||||
continue
|
||||
if latest_nodeinfo is None:
|
||||
latest_nodeinfo = nodeinfo
|
||||
if len(nodeinfo.public_key) == 32:
|
||||
public_keys.add(nodeinfo.public_key)
|
||||
|
||||
if latest_nodeinfo is not None:
|
||||
if len(latest_nodeinfo.public_key) == 32:
|
||||
user.public_key = latest_nodeinfo.public_key
|
||||
user.is_licensed = latest_nodeinfo.is_licensed
|
||||
if latest_nodeinfo.HasField("is_unmessagable"):
|
||||
user.is_unmessagable = latest_nodeinfo.is_unmessagable
|
||||
|
||||
contact_bytes = contact.SerializeToString()
|
||||
import base64
|
||||
|
||||
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
|
||||
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
|
||||
# A contact without a public key is refused by current Meshtastic apps and, on older
|
||||
# firmware, erases the key the importing radio already has. Importing a contact also
|
||||
# replaces a key the radio has pinned, so when the node's packets disagree on its key
|
||||
# (one of them may be forged), share neither.
|
||||
public_key_conflict = len(public_keys) > 1
|
||||
meshtastic_url = None
|
||||
if user.public_key and not public_key_conflict:
|
||||
contact = SharedContact()
|
||||
contact.node_num = node_id
|
||||
contact.user.CopyFrom(user)
|
||||
contact.manually_verified = False
|
||||
|
||||
meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
|
||||
contact_bytes = contact.SerializeToString()
|
||||
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
|
||||
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
|
||||
|
||||
meshtastic_url = f"https://meshtastic.org/v/#{contact_b64url}"
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
@@ -1218,6 +1309,10 @@ async def api_node_qr(request):
|
||||
"long_name": node.long_name,
|
||||
"short_name": node.short_name,
|
||||
"role": node.role,
|
||||
"public_key": (
|
||||
base64.b64encode(user.public_key).decode("ascii") if user.public_key else None
|
||||
),
|
||||
"public_key_conflict": public_key_conflict,
|
||||
"meshtastic_url": meshtastic_url,
|
||||
}
|
||||
)
|
||||
@@ -1232,7 +1327,7 @@ async def api_node_qr(request):
|
||||
@routes.get("/api/coverage/{node_id}")
|
||||
async def api_coverage(request):
|
||||
try:
|
||||
node_id = int(request.match_info["node_id"], 0)
|
||||
node_id = parse_db_int(request.match_info["node_id"], 0)
|
||||
except (KeyError, ValueError):
|
||||
return web.json_response({"error": "Invalid node_id"}, status=400)
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "meshview"
|
||||
version = "3.0.0"
|
||||
version = "4.0.0"
|
||||
description = "Real-time monitoring and diagnostic tool for the Meshtastic mesh network"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
+1
-1
@@ -347,7 +347,7 @@ async def load_database_from_mqtt(
|
||||
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)
|
||||
logger.exception("Failed to store a message from topic %r; skipping it", topic)
|
||||
|
||||
|
||||
# -------------------------
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Persist reported altitudes without losing them when a report omits altitude."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from meshtastic.protobuf.mesh_pb2 import Position
|
||||
from meshtastic.protobuf.mqtt_pb2 import MapReport, ServiceEnvelope
|
||||
from meshtastic.protobuf.portnums_pb2 import PortNum
|
||||
from meshview import mqtt_database, mqtt_store
|
||||
from meshview.models import Base, Node
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("report_type", [Position, MapReport])
|
||||
async def test_altitude_updates(monkeypatch, report_type):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(mqtt_database, "async_session", sessions, raising=False)
|
||||
monkeypatch.setattr(mqtt_store, "MQTT_GATEWAY_CACHE", set())
|
||||
port = PortNum.POSITION_APP if report_type is Position else PortNum.MAP_REPORT_APP
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
cases = [
|
||||
(None, 200000000, None),
|
||||
(125, 200000000, 125),
|
||||
(None, 200000000, 125),
|
||||
(-30, 200000000, -30),
|
||||
(900, 0, -30), # Invalid location must not update altitude.
|
||||
(0, 200000000, 0 if report_type is Position else -30),
|
||||
]
|
||||
for packet_id, (altitude, coordinate, expected) in enumerate(cases, start=1):
|
||||
report = report_type(latitude_i=coordinate, longitude_i=coordinate)
|
||||
if altitude is not None:
|
||||
report.altitude = altitude
|
||||
env = ServiceEnvelope(gateway_id="!00000002", channel_id="test")
|
||||
env.packet.id = packet_id
|
||||
setattr(env.packet, "from", 123)
|
||||
env.packet.decoded.portnum = port
|
||||
env.packet.decoded.payload = report.SerializeToString()
|
||||
await mqtt_store.process_envelope("test", env)
|
||||
async with sessions() as session:
|
||||
node = (await session.execute(select(Node).where(Node.node_id == 123))).scalar_one()
|
||||
assert node.last_altitude == expected
|
||||
assert node.to_dict()["last_altitude"] == expected
|
||||
finally:
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Reliability samples must select the newest packets within the main channel."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
|
||||
from meshview import database, models, store
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_channel_filter_applies_before_limit(monkeypatch):
|
||||
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(database, "async_session", sessions)
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await conn.run_sync(models.Base.metadata.create_all)
|
||||
async with sessions() as session:
|
||||
session.add_all(
|
||||
[
|
||||
models.Packet(
|
||||
id=1, from_node_id=123, portnum=1, channel="Main", import_time_us=100
|
||||
),
|
||||
models.Packet(
|
||||
id=2, from_node_id=123, portnum=1, channel="Main", import_time_us=200
|
||||
),
|
||||
models.Packet(
|
||||
id=3, from_node_id=123, portnum=1, channel="Other", import_time_us=300
|
||||
),
|
||||
models.Packet(
|
||||
id=4, from_node_id=456, portnum=1, channel="Main", import_time_us=400
|
||||
),
|
||||
models.Packet(
|
||||
id=5, from_node_id=123, portnum=3, channel="Main", import_time_us=500
|
||||
),
|
||||
]
|
||||
)
|
||||
await session.commit()
|
||||
packets = await store.get_packets(from_node_id=123, portnum=1, channel="Main", limit=2)
|
||||
assert [p.id for p in packets] == [2, 1]
|
||||
packets = await store.get_packets(from_node_id=123, portnum=1, limit=2)
|
||||
assert [p.id for p in packets] == [3, 2]
|
||||
assert await store.get_packets(from_node_id=123, channel="Unknown", limit=2) == []
|
||||
finally:
|
||||
await engine.dispose()
|
||||
Reference in new issue
Block a user