mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-10-31 03:01:58 +01:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e34c120e | ||
|
|
63abac5448 | ||
|
|
65fb307d07 | ||
|
|
f5d5dd6049 | ||
|
|
8446ef8956 | ||
|
|
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
+143
-128
@@ -1,96 +1,75 @@
|
||||
# Running MeshView with Docker
|
||||
|
||||
MeshView container images are built automatically and published to GitHub Container Registry.
|
||||
The examples below use a Bash-compatible shell and Docker Compose v2 (`docker compose`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Pull and run the latest image:
|
||||
Prepare the configuration and persistent directories before starting the container:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
-p 8081:8081 \
|
||||
-v ./config:/etc/meshview \
|
||||
-v ./data:/var/lib/meshview \
|
||||
-v ./logs:/var/log/meshview \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
Access the web interface at: http://localhost:8081
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
The container uses three volumes for persistent data:
|
||||
|
||||
| Volume | Purpose | Required |
|
||||
|--------|---------|----------|
|
||||
| `/etc/meshview` | Configuration files | Yes |
|
||||
| `/var/lib/meshview` | Database storage | Recommended |
|
||||
| `/var/log/meshview` | Log files | Optional |
|
||||
|
||||
### Configuration Volume
|
||||
|
||||
Mount a directory containing your `config.ini` file:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/config:/etc/meshview
|
||||
```
|
||||
|
||||
If no config is provided, the container will use the default `sample.config.ini`.
|
||||
|
||||
### Database Volume
|
||||
|
||||
Mount a directory to persist the SQLite database:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/data:/var/lib/meshview
|
||||
```
|
||||
|
||||
**Important:** Without this mount, your database will be lost when the container stops.
|
||||
|
||||
### Logs Volume
|
||||
|
||||
Mount a directory to access logs from the host:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/logs:/var/log/meshview
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Create a directory structure and run:
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p meshview/{config,data,logs,backups}
|
||||
|
||||
# Copy sample config (first time only)
|
||||
docker run --rm ghcr.io/pablorevilla-meshtastic/meshview:latest \
|
||||
cat /etc/meshview/config.ini > meshview/config/config.ini
|
||||
# First-time setup only: extract the image's bundled configuration.
|
||||
# --entrypoint bypasses the normal MeshView startup command.
|
||||
docker run --rm --entrypoint cat \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest \
|
||||
/etc/meshview/config.ini > meshview/config/config.ini
|
||||
|
||||
# Edit config.ini with your MQTT settings
|
||||
# Edit MQTT settings, site settings, and backup options before starting.
|
||||
nano meshview/config/config.ini
|
||||
|
||||
# Run the container
|
||||
# On Linux, give the container user (UID 10001) ownership of writable data.
|
||||
sudo chown -R 10001 meshview/data meshview/logs meshview/backups
|
||||
sudo chmod -R u+rwX meshview/data meshview/logs meshview/backups
|
||||
# The configuration stays owned by you and must be readable by the container.
|
||||
chmod 755 meshview/config
|
||||
chmod 644 meshview/config/config.ini
|
||||
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
--restart unless-stopped \
|
||||
-p 8081:8081 \
|
||||
-v $(pwd)/meshview/config:/etc/meshview \
|
||||
-v $(pwd)/meshview/data:/var/lib/meshview \
|
||||
-v $(pwd)/meshview/logs:/var/log/meshview \
|
||||
-v "$(pwd)/meshview/config:/etc/meshview:ro" \
|
||||
-v "$(pwd)/meshview/data:/var/lib/meshview" \
|
||||
-v "$(pwd)/meshview/logs:/var/log/meshview" \
|
||||
-v "$(pwd)/meshview/backups:/var/lib/meshview/backups" \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
Run the config extraction only for a new installation: the redirection overwrites any
|
||||
existing `config.ini`. On Docker Desktop, host file permissions may be managed differently;
|
||||
the requirement is that UID 10001 can read the configuration and write data, logs, and backups.
|
||||
|
||||
Access the web interface at http://localhost:8081. Check startup with `docker logs -f meshview`.
|
||||
Database migrations run automatically when MeshView starts.
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Container path | Purpose | Host directory in the examples |
|
||||
|----------------|---------|--------------------------------|
|
||||
| `/etc/meshview` | Read-only configuration containing `config.ini` | `meshview/config` |
|
||||
| `/var/lib/meshview` | SQLite database storage | `meshview/data` |
|
||||
| `/var/log/meshview` | File logs, when enabled | `meshview/logs` |
|
||||
| `/var/lib/meshview/backups` | Database backups, when enabled | `meshview/backups` |
|
||||
|
||||
An empty host configuration directory hides the image's bundled `config.ini` and prevents
|
||||
startup. There is no fallback when a mounted configuration directory lacks that file.
|
||||
If you omit the configuration mount entirely, the image's bundled configuration is used.
|
||||
|
||||
Stopping or restarting a container does not delete the database. Explicit host mounts make
|
||||
it straightforward to preserve and reuse data when removing or recreating containers.
|
||||
Without explicit mounts, the image declares anonymous volumes; do not rely on those being
|
||||
automatically reused by a replacement container, and do not delete them if you need their data.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
Use the preparation steps from Quick Start, but skip its `docker run -d` command.
|
||||
Create `compose.yaml` **inside the `meshview` directory**, alongside `config`, `data`,
|
||||
`logs`, and `backups`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
meshview:
|
||||
image: ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
@@ -99,45 +78,57 @@ services:
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/etc/meshview
|
||||
- ./config:/etc/meshview:ro
|
||||
- ./data:/var/lib/meshview
|
||||
- ./logs:/var/log/meshview
|
||||
- ./backups:/var/lib/meshview/backups # For database backups
|
||||
- ./backups:/var/lib/meshview/backups
|
||||
environment:
|
||||
- TZ=America/Los_Angeles # Set your timezone
|
||||
TZ: America/Los_Angeles
|
||||
```
|
||||
|
||||
Run with:
|
||||
Start from that directory:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
cd meshview
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Choose either Compose or `docker run` to manage the installation. If switching from the
|
||||
Quick Start container to Compose, stop and remove the old container first, keeping its
|
||||
host directories: `docker stop meshview` followed by `docker rm meshview`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Minimum Configuration
|
||||
|
||||
Edit your `config.ini` to configure MQTT connection:
|
||||
Edit the extracted `config.ini` rather than replacing it with a partial configuration.
|
||||
Keep the container server listening on all interfaces:
|
||||
|
||||
```ini
|
||||
[mqtt]
|
||||
server = mqtt.meshtastic.org
|
||||
topics = ["msh/US/#"]
|
||||
port = 1883
|
||||
username =
|
||||
password =
|
||||
[server]
|
||||
bind = 0.0.0.0
|
||||
port = 8081
|
||||
```
|
||||
|
||||
Set your MQTT broker, topics, and credentials in the existing `[mqtt]` section. The broker
|
||||
must be reachable from the container; `localhost` refers to the container itself.
|
||||
|
||||
Keep the database path inside the mounted data directory:
|
||||
|
||||
```ini
|
||||
[database]
|
||||
# SQLAlchemy async connection string.
|
||||
# Examples:
|
||||
# sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:////var/lib/meshview/packets.db
|
||||
```
|
||||
|
||||
The four slashes after `sqlite+aiosqlite:` specify an absolute Linux path. PostgreSQL
|
||||
is also supported with a connection string such as
|
||||
`postgresql+asyncpg://user:pass@host:5432/meshview`.
|
||||
|
||||
After configuration changes, restart the application with `docker restart meshview`, or
|
||||
`docker compose restart meshview` for a Compose installation.
|
||||
|
||||
### Database Backups
|
||||
|
||||
To enable automatic daily backups inside the container:
|
||||
Enable backups in the existing `[cleanup]` section:
|
||||
|
||||
```ini
|
||||
[cleanup]
|
||||
@@ -147,99 +138,123 @@ backup_hour = 2
|
||||
backup_minute = 00
|
||||
```
|
||||
|
||||
Then mount the backups directory:
|
||||
|
||||
```bash
|
||||
-v $(pwd)/meshview/backups:/var/lib/meshview/backups
|
||||
```
|
||||
Both startup examples mount this directory to `meshview/backups` on the host. Backups
|
||||
remain disabled until you enable them in the configuration.
|
||||
|
||||
## Available Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest build from the main branch |
|
||||
| `dev-v3` | Development branch |
|
||||
| `v1.2.3` | Specific version tags |
|
||||
| `latest` | Latest build from the repository's default branch |
|
||||
| Branch name | Builds published for that branch |
|
||||
| `3.0.6` | Example version tag; use a version published in the registry |
|
||||
|
||||
## Updating
|
||||
|
||||
Pull the latest image and restart:
|
||||
For a Compose installation, run from the directory containing `compose.yaml`:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For a `docker run` installation, run from the same parent directory used in Quick Start:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
docker restart meshview
|
||||
docker stop meshview
|
||||
docker rm meshview
|
||||
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
--restart unless-stopped \
|
||||
-p 8081:8081 \
|
||||
-v "$(pwd)/meshview/config:/etc/meshview:ro" \
|
||||
-v "$(pwd)/meshview/data:/var/lib/meshview" \
|
||||
-v "$(pwd)/meshview/logs:/var/log/meshview" \
|
||||
-v "$(pwd)/meshview/backups:/var/lib/meshview/backups" \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
Or with docker-compose:
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
Preserve any custom ports, mounts, or environment settings when recreating the container.
|
||||
Do not repeat the first-time configuration extraction during updates. Pulling an image
|
||||
and merely restarting an existing container does not switch it to the new image.
|
||||
|
||||
## Logs
|
||||
|
||||
View container logs:
|
||||
|
||||
```bash
|
||||
docker logs meshview
|
||||
|
||||
# Follow logs
|
||||
docker logs -f meshview
|
||||
|
||||
# Last 100 lines
|
||||
docker logs --tail 100 meshview
|
||||
```
|
||||
|
||||
For Compose installations, `docker compose logs -f meshview` also works.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker logs meshview
|
||||
```
|
||||
Check `docker logs meshview`. Verify that the mounted `config` directory contains a
|
||||
readable `config.ini`, and that the database and log directories are writable by UID 10001.
|
||||
|
||||
### Docker Desktop reports mounts denied
|
||||
|
||||
Make sure the installation directory is shared with Docker Desktop under Settings →
|
||||
Resources → File Sharing. Bind-mount source paths must exist on the Docker daemon's
|
||||
host; a remote daemon cannot mount directories that exist only on your client.
|
||||
|
||||
### Database permission issues
|
||||
|
||||
Ensure the data directory is writable:
|
||||
From the parent directory containing `meshview`, repair writable directory ownership:
|
||||
|
||||
```bash
|
||||
chmod -R 755 meshview/data
|
||||
sudo chown -R 10001 meshview/data meshview/logs meshview/backups
|
||||
sudo chmod -R u+rwX meshview/data meshview/logs meshview/backups
|
||||
```
|
||||
|
||||
Setting permissions to `755` alone does not let UID 10001 write to directories owned
|
||||
by another user. SQLite also needs to create journal/WAL files beside the database.
|
||||
|
||||
### Can't connect to MQTT
|
||||
|
||||
1. Check your MQTT configuration in `config.ini`
|
||||
2. Verify network connectivity from the container:
|
||||
```bash
|
||||
docker exec meshview ping mqtt.meshtastic.org
|
||||
```
|
||||
Check the broker hostname, port, credentials, and topics in `config.ini`.
|
||||
Test DNS resolution and a TCP connection using Python, which is included in the image:
|
||||
|
||||
```bash
|
||||
# Replace the hostname and port with your configured broker.
|
||||
docker exec meshview /opt/venv/bin/python -c \
|
||||
'import socket; s = socket.create_connection(("mqtt.meshtastic.org", 1883), timeout=10); print("TCP connection successful"); s.close()'
|
||||
```
|
||||
|
||||
This checks network connectivity, not MQTT authentication or topic permissions.
|
||||
The image does not include `ping`.
|
||||
|
||||
### Port already in use
|
||||
|
||||
Change the host port (left side):
|
||||
Change the host port (left side) to `8082`, keeping the container port at `8081`:
|
||||
|
||||
```bash
|
||||
-p 8082:8081
|
||||
```
|
||||
|
||||
Then access at: http://localhost:8082
|
||||
For Compose, use `"8082:8081"` under `ports`. Access http://localhost:8082.
|
||||
|
||||
## Building Your Own Image
|
||||
|
||||
If you want to build from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pablorevilla-meshtastic/meshview.git
|
||||
cd meshview
|
||||
docker build -f Containerfile -t meshview:local .
|
||||
```
|
||||
|
||||
Use `meshview:local` instead of the registry image in the setup and run commands.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- The container runs as a non-root user (`app`, UID 10001)
|
||||
- No privileged access required
|
||||
- Only port 8081 is exposed
|
||||
- All data stored in mounted volumes
|
||||
- The container runs as a non-root user (`app`, UID 10001).
|
||||
- No privileged access is required.
|
||||
- The examples publish port 8081 on the host; configuration is mounted read-only.
|
||||
- Database, file logs, and backups use the mounted directories shown above.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -86,8 +86,9 @@
|
||||
if (!recentPackets.length) { note(container, 'No recent packets found.'); return; }
|
||||
container.replaceChildren();
|
||||
for (const packet of recentPackets) {
|
||||
const button = document.createElement('a');
|
||||
button.href = `/packet/${encodeURIComponent(packetId(packet))}`;
|
||||
const hasPacketLink = Number(packet.portnum) !== 73 && Number(packetId(packet)) > 0;
|
||||
const button = document.createElement(hasPacketLink ? 'a' : 'div');
|
||||
if (hasPacketLink) button.href = `/packet/${encodeURIComponent(packetId(packet))}`;
|
||||
button.className = 'mesh-inspector-packet';
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = `${portName(packet)} · ${timeText(packet.import_time_us)}`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
.mesh-inspector-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
|
||||
.mesh-inspector-actions a { font-size:12px; }
|
||||
.mesh-inspector-packet { display:block; width:100%; text-align:left; padding:10px 0; border:0; border-bottom:1px solid var(--mesh-border); background:var(--mesh-surface); color:var(--mesh-text); font-size:12px; text-decoration:none; }
|
||||
.mesh-inspector-packet:hover { background:var(--mesh-soft); }
|
||||
a.mesh-inspector-packet:hover { background:var(--mesh-soft); }
|
||||
.mesh-inspector-packet strong,.mesh-inspector-packet small { display:block; }
|
||||
.mesh-inspector-packet small { color:var(--mesh-muted); margin-top:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mesh-inspector-note { color:var(--mesh-muted); font-size:12px; margin:8px 0; }
|
||||
@@ -162,7 +162,7 @@
|
||||
.mesh-traffic-tools { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.mesh-traffic-status { font-size:11px; color:var(--mesh-primary); }
|
||||
.mesh-traffic-status[data-state="error"] { color:#b23d2f; }
|
||||
.mesh-traffic-list { max-height:260px; overflow:auto; }
|
||||
.mesh-traffic-list { max-height:360px; overflow:auto; }
|
||||
.mesh-traffic-table { width:100%; min-width:780px; border-collapse:collapse; font-size:13px; color:var(--mesh-text); }
|
||||
.mesh-traffic-table th { position:sticky; top:0; z-index:1; padding:12px 14px; background:var(--mesh-brand); color:white; font-weight:500; font-size:12px; white-space:nowrap; text-align:left; }
|
||||
.mesh-traffic-table td { padding:11px 14px; border-bottom:1px solid var(--mesh-border); text-align:left; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+297
-15
@@ -15,12 +15,24 @@
|
||||
.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; }
|
||||
#altitude-map-button { margin-left:auto; }
|
||||
#altitude-map-modal[hidden] { display:none; }
|
||||
#altitude-map-modal { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; padding:16px; background:#17263c99; }
|
||||
.altitude-map-dialog { width:min(1100px,100%); height:85dvh; display:flex; flex-direction:column; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:10px; padding:16px; }
|
||||
.altitude-map-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.altitude-map-heading h2 { font-size:18px; margin:0; }
|
||||
#altitude-map-status { color:var(--mesh-muted); font-size:13px; margin:10px 0; }
|
||||
#altitude-router-map { flex:1; min-height:0; border-radius:6px; }
|
||||
.altitude-router-icon { background:transparent; border:0; }
|
||||
.altitude-router-icon svg { width:32px; height:32px; filter:drop-shadow(0 2px 3px #0005); }
|
||||
.altitude-router-label { background:var(--mesh-surface); color:var(--mesh-text); border-color:var(--mesh-border); box-shadow:none; font-size:11px; }
|
||||
#modalChart { flex:1; min-height:0; width:100%; }
|
||||
@media(max-width:1000px) { .stats-chart-grid { grid-template-columns:minmax(0,1fr); } }
|
||||
@media(max-width:700px) { .summary-container { grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } .summary-card { padding:16px; } .summary-count { font-size:26px; } .card-section { padding:14px; gap:8px; } .chart { height:320px; } }
|
||||
@@ -28,6 +40,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||
<script src="/static/portmaps.js"></script>
|
||||
{% endblock %}
|
||||
@@ -91,6 +105,27 @@
|
||||
<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 top 50 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 top 50 CSV</button>
|
||||
<button id="altitude-map-button" type="button" disabled>Map top 50</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 at least 50 m above sea level 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">
|
||||
@@ -181,6 +216,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="altitude-map-modal" role="dialog" aria-modal="true" aria-labelledby="altitude-map-title" hidden>
|
||||
<div class="altitude-map-dialog">
|
||||
<div class="altitude-map-heading"><h2 id="altitude-map-title">highest routers · top 50</h2><button id="altitude-map-close" type="button" aria-label="Close router map">×</button></div>
|
||||
<p id="altitude-map-status" role="status"></p>
|
||||
<div id="altitude-router-map" aria-label="Map of highest routers and their altitudes"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for expanded charts -->
|
||||
<div id="chartModal" role="dialog" aria-modal="true" aria-label="Expanded chart">
|
||||
<div>
|
||||
@@ -192,6 +235,214 @@
|
||||
<script>
|
||||
const PORTNUM_LABELS = window.PORT_LABEL_MAP;
|
||||
|
||||
let statsNodesPromise;
|
||||
let textNodeRows = [];
|
||||
let textNodeExportRows = [];
|
||||
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 = [];
|
||||
textNodeExportRows = [];
|
||||
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);
|
||||
textNodeExportRows = ranked.slice(0, 50);
|
||||
textNodeRows = textNodeExportRows.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 = [];
|
||||
let altitudeExportRows = [];
|
||||
function rankAltitudeNodes(nodes) {
|
||||
return nodes
|
||||
.filter(node => Number.isFinite(node.last_altitude) && node.last_altitude >= 50 && 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);
|
||||
}
|
||||
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;
|
||||
document.getElementById("altitude-map-button").disabled = true;
|
||||
status.textContent = "Loading…";
|
||||
chart.off("click");
|
||||
chart.on("click", openAltitudeNode);
|
||||
try {
|
||||
altitudeExportRows = rankAltitudeNodes(await fetchStatsNodes(refreshNodes)).slice(0, 50);
|
||||
altitudeRows = altitudeExportRows.slice(0, 10);
|
||||
document.getElementById("altitude-map-button").disabled = altitudeExportRows.length === 0;
|
||||
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", color:"#243247", textBorderWidth:0},
|
||||
data:altitudeRows.map(node => node.last_altitude)}]
|
||||
}, true);
|
||||
status.textContent = altitudeRows.length
|
||||
? `Showing ${altitudeRows.length} nodes · highest first`
|
||||
: "No routers have reported altitude of 50 m or more.";
|
||||
} catch (error) {
|
||||
altitudeRows = [];
|
||||
altitudeExportRows = [];
|
||||
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();
|
||||
|
||||
// Map the same ranked sample used by the top-50 CSV export.
|
||||
let altitudeMap;
|
||||
let altitudeMarkers;
|
||||
const altitudeMapModal = document.getElementById("altitude-map-modal");
|
||||
const altitudeMapButton = document.getElementById("altitude-map-button");
|
||||
const altitudeMapClose = document.getElementById("altitude-map-close");
|
||||
function closeAltitudeMap() {
|
||||
altitudeMapModal.hidden = true;
|
||||
altitudeMapButton.focus();
|
||||
}
|
||||
altitudeMapClose.addEventListener("click", closeAltitudeMap);
|
||||
altitudeMapModal.addEventListener("click", event => {
|
||||
if (event.target === altitudeMapModal) closeAltitudeMap();
|
||||
});
|
||||
altitudeMapModal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); closeAltitudeMap(); }
|
||||
if (event.key === "Tab") {
|
||||
const controls = [...altitudeMapModal.querySelectorAll('button, a[href], [tabindex="0"]')]
|
||||
.filter(el => el.getClientRects().length);
|
||||
const first = controls[0], last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
});
|
||||
altitudeMapButton.addEventListener("click", () => {
|
||||
altitudeMapModal.hidden = false;
|
||||
altitudeMapClose.focus();
|
||||
if (!altitudeMap) {
|
||||
altitudeMap = L.map("altitude-router-map").setView([0,0], 2);
|
||||
L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
|
||||
attribution:'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, SRTM | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
|
||||
maxZoom:17
|
||||
}).addTo(altitudeMap);
|
||||
altitudeMarkers = L.layerGroup().addTo(altitudeMap);
|
||||
}
|
||||
altitudeMarkers.clearLayers();
|
||||
const located = altitudeExportRows.filter(node =>
|
||||
Number.isFinite(node.last_lat) && Number.isFinite(node.last_long)
|
||||
&& Math.abs(node.last_lat) <= 900000000 && Math.abs(node.last_long) <= 1800000000
|
||||
&& (Math.abs(node.last_lat) > 10000000 || Math.abs(node.last_long) > 10000000));
|
||||
const icon = L.divIcon({className:"altitude-router-icon", iconSize:[32,32], iconAnchor:[16,16],
|
||||
html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="15" fill="#187346" stroke="white" stroke-width="2"/><g fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 21h12v4H10zM16 21v-7m-4 1a6 6 0 0 1 8 0m-11-3a10 10 0 0 1 14 0"/></g><circle cx="16" cy="12" r="1.5" fill="white"/></svg>'});
|
||||
located.forEach(node => {
|
||||
const marker = L.marker([node.last_lat / 1e7,node.last_long / 1e7], {icon}).addTo(altitudeMarkers);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `${node.short_name || node.long_name || node.id} · ${node.last_altitude.toLocaleString()} m`;
|
||||
marker.bindTooltip(label, {permanent:true, direction:"top", offset:[0,-16], className:"altitude-router-label"});
|
||||
const details = document.createElement("div");
|
||||
const link = document.createElement("a");
|
||||
link.href = `/node/${encodeURIComponent(node.node_id)}`;
|
||||
link.textContent = node.long_name || node.short_name || node.id;
|
||||
const altitude = document.createElement("p");
|
||||
altitude.textContent = `${node.last_altitude.toLocaleString()} m above sea level · ${node.role}`;
|
||||
details.append(link,altitude);
|
||||
marker.bindPopup(details);
|
||||
});
|
||||
const missing = altitudeExportRows.length - located.length;
|
||||
document.getElementById("altitude-map-status").textContent =
|
||||
`Showing ${located.length} of the top ${altitudeExportRows.length} routers · altitude in meters above sea level` +
|
||||
(missing ? ` · ${missing} without a valid location` : '');
|
||||
requestAnimationFrame(() => {
|
||||
altitudeMap.invalidateSize();
|
||||
if (located.length) altitudeMap.fitBounds(located.map(node => [node.last_lat / 1e7,node.last_long / 1e7]), {padding:[60,60], maxZoom:12});
|
||||
else altitudeMap.setView([0,0],2);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Fetch & Processing ---
|
||||
async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||
try{
|
||||
@@ -214,14 +465,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 +508,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 +556,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 +596,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 +888,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 +901,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 +918,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)"],
|
||||
...altitudeExportRows.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"],
|
||||
...textNodeExportRows.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