29 Commits
Author SHA1 Message Date
pablorevilla-meshtastic e1e34c120e update 2026-10-29 16:05:47 -07:00
pablorevilla-meshtastic 63abac5448 added new map 2026-10-29 16:04:24 -07:00
pablorevilla-meshtastic 65fb307d07 fixed node inspector 2026-10-29 15:37:52 -07:00
pablorevilla-meshtastic f5d5dd6049 more updates 2026-10-29 13:10:17 -07:00
pablorevilla-meshtastic 8446ef8956 fixed colors in report 2026-10-29 10:47:51 -07:00
pablorevilla-meshtastic 7c8f5c42d0 fix one of the reports 2026-10-28 11:43:10 -07:00
pablorevilla-meshtastic 11b6a92f71 update 2026-10-28 11:26:11 -07:00
pablorevilla-meshtastic d022ec455e more updates 2026-10-28 11:21:57 -07:00
pablorevilla-meshtastic acba2284c5 more altitude 2026-10-28 11:10:52 -07:00
pablorevilla-meshtastic dddc8e0a13 more altitude details 2026-10-28 10:57:26 -07:00
pablorevilla-meshtastic 93fc31efd4 add altitude to table 2026-10-28 10:39:25 -07:00
pablorevilla-meshtastic 3e1d95ed2c fis 0 lat and long 2026-10-28 10:31:18 -07:00
pablorevilla-meshtastic ec9fafc362 change icon on light mode 2026-10-28 10:10:17 -07:00
pablorevilla-meshtastic 0e424da10a new stats report 2026-10-28 09:14:37 -07:00
pablorevilla-meshtastic 7ad590a83c update 2026-10-28 07:51:46 -07:00
pablorevilla-meshtastic b80a0b27df node page optimization 2026-10-28 07:49:04 -07:00
pablorevilla-meshtastic 330ee47932 updated version 2026-10-28 07:44:30 -07:00
pablorevilla-meshtastic 29860e8e7d fix report 2026-10-28 07:38:31 -07:00
pablorevilla-meshtastic ef52e96d7c fix date on neighour repor 2026-10-28 07:35:42 -07:00
pablorevilla-meshtastic acfd71bd2e fix 2nd key funtion 2026-10-27 19:50:39 -07:00
pablorevilla-meshtastic f570fdb65a Merge develop-4 UI updates into develop 2026-10-27 17:27:15 -07:00
11fb764fa8 Keep one malformed packet from breaking API endpoints or node updates (#176)
* Keep one malformed packet from breaking API endpoints or node updates

Values in a packet come from the mesh, so a forged packet or a newer firmware can carry
data the code did not expect:

- Routing.error_reason with a number the bundled protobufs do not define made
  Routing.Error.Name() raise, and /api/packets returned 500 for every list that included
  that packet.
- An unknown DeviceConfig.Role or HardwareModel in MAP_REPORT or NODEINFO raised the same
  way, and the whole node update was dropped.
- rx_snr of +inf was stored and served as "Infinity", which is not valid JSON, so the
  packet page's "seen by" list failed to load.
- A NEIGHBORINFO packet whose payload does not parse made /api/edges return 500 while it
  was among the 50 most recent.

Add decode_payload.enum_name(), which returns "unknown(N)" for undefined values, and use it
at all five enum lookups. Store non-finite SNR as NULL and serve existing ones as null.
Skip NEIGHBORINFO packets that do not decode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a blank line that fails the ruff format check

`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:20:08 -07:00
c0c9a499d5 Escape node names and channels in chart tooltips and parsed telemetry (#177)
Three places still put mesh-provided strings into HTML unescaped:

- stats.html: the pie-chart tooltip formatter returned params.name raw. The charts cover
  channel, hardware model, role and gateway firmware, so a channel or firmware string ran
  as HTML when someone hovered its slice.
- traceroute.html: the node tooltip formatter returned the long name raw.
- packet.html: "PDOP", "sats_in_view", "ground_speed" and "altitude" values parsed from
  the payload text went into the card unescaped. A broadcast text message reading
  "PDOP: <img ...>" is enough.

Use the existing escapeHtml() from static/html-escape.js in all three.

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:19:16 -07:00
cfa71f9235 Keep one node's data from stopping the kiosk map or skewing the stats pies (#178)
/kiosk builds every marker in one forEach, so a single node with unexpected data stops
the whole page:

- node.role.toLowerCase() throws when role is null. Nodes first heard through a POSITION
  packet are created by ensure_node_exists() without a role, so on a fresh database the
  kiosk draws no nodes at all once one of them has a position.
- markers is a plain object keyed by channel name. A node on a channel called "__proto__",
  "constructor" or "toString" finds an existing non-array value there, and .push() throws.

Either error aborts the loop, and the channel filters and live packet blinking after it
never start. Treat a missing role as "not a router" and keep markers in a Map.

/stats counted values per field in a plain object too, so a channel called "constructor"
was counted as the string "function Object() { [native code] }1" and "__proto__" was lost.
Count in a Map there as well.

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:18:36 -07:00
5f3d37bf06 Build every CSV export through one helper that quotes cells and defuses formulas (#179)
The four "Export CSV" buttons each built CSV by hand, from values that come from the mesh:

- /nodelist used a data: URL through encodeURI(), which leaves "#" alone, so the file
  ended at the first "#" in any cell. Real node names contain one ("Big Rock Ridge #1").
- The node page's chart export (neighbors, telemetry) did not quote cells at all.
- /stats quoted cells but did not double quotes inside them, so a channel such as
  Mesh "West", Bay split into extra columns.
- None of them guarded against spreadsheet formulas. A node name, channel, firmware string
  or message that starts with = + - or @ opens as a live formula in Excel or LibreOffice.

Add static/csv.js (loaded from base.html, like html-escape.js) with csvCell(), which
quotes every cell, doubles inner quotes and prefixes formula-like text with ', leaving
plain numbers alone, and csvFromRows(). All four exports use it; /nodelist now downloads a
Blob like the others.

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:18:11 -07:00
1e9b1a3ce3 Answer 400, not 500, for malformed or out-of-range numeric parameters (#180)
* Answer 400, not 500, for malformed or out-of-range numeric parameters

Many endpoints turned query or path values into integers with a bare int(), so
?length=abc on /api/channels or /api/stats/top, /api/traceroute/abc and
/graph/traceroute/abc answered with an unhandled exception and a 500.

Values that do parse could still fail: node and packet ids are BIGINT and portnum is
INTEGER, and a longer number made the database driver raise, which also answered 500
(/api/packets?node_id=99999999999999999999999, /api/packets_seen/<same>, the node QR and
coverage routes, /api/stats and /api/stats/count filters). On PostgreSQL that includes
any portnum outside 32 bits.

length had no upper bound, so a huge value overflowed timedelta (500) or widened the
aggregate to the whole table. On SQLite a negative limit means "no limit", so
/api/stats/top?limit=-1 returned every node and skipped its 100-row cap.

Add parse_db_int(), which also rejects values outside the column's range, plus two small
helpers that answer 400 with a message. Clamp length to 1..3650, limit to 1..100 and
offset to >= 0. Endpoints that already ignored an unparseable filter (the /api/packets
node, portnum and time filters) treat an out-of-range one the same way. Valid requests are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a blank line that fails the ruff format check

`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:16:50 -07:00
d66c5d21e9 Log request values and MQTT topics with %r so they cannot add log lines (#181)
* Log request values and MQTT topics with %r so they cannot add log lines

/api/packets logged an unparseable since, portnum or node id filter with an f-string, so
the decoded query value went into the log as is. ?since=1%0A<fake line> wrote a second,
forged log line, and escape sequences (ESC, BEL) reached whoever reads the log in a
terminal. The ingest error added in startdb.py logged the MQTT topic with %s.

Use %r for these values, as the gateway_id warning in mqtt_store.py already does, so CR,
LF, ESC and other control characters appear escaped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a blank line that fails the ruff format check

`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:15:33 -07:00
a5f46d6360 Bound the traceroute data a request has to draw (#182)
* Bound the traceroute data a request has to draw

Every TRACEROUTE message is stored as a new row under its packet id, and its route can list
any number of hops. /graph/traceroute decodes every row, looks up every node on every route
and runs Graphviz over the result inside the request handler, so a publisher can make one
page view take a very long time and stall every other request meanwhile:

- 3,000 rows of 8 random hops for one packet id: the graph took about 43 s and other
  requests waited up to 24 s.
- 150 rows of 400 hops: about 40 s, then a 500, with other requests waiting about 10 s.

Real traceroutes are small. In 17 days of bay-area traffic no packet id had more than 72
rows, and none of 66,709 routes had more than 8 hops in either direction, which is the
firmware's limit.

get_traceroute() now returns at most the 200 earliest rows, and the TRACEROUTE decoder
treats a route longer than 8 hops as undecodable, which every caller already skips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove a blank line that fails the ruff format check

`ruff format --check .` (the Ruff workflow, ruff 0.13.3) fails on develop because of this
line, so every pull request's lint job is red regardless of its content.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Nathan <201262769+nullrouten0@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:10:03 -07:00
Mike BennerandClaude Opus 5.5 ae7ba15263 Include the node's public key in the contact QR code (#185)
* Include the node's public key in the contact QR code #184

The contact QR code and import URL never set user.public_key. Current
Meshtastic apps refuse to import a keyless contact. On 2.7.x firmware,
an older app's add_contact also overwrites the key the radio already
holds, so direct messages to that node fail.

Read the key from the node's latest stored NODEINFO_APP packet. Share
the contact only when a 32-byte key is known. Otherwise the node page
hides the QR buttons and says why. The QR API response now also
returns public_key.

Also drop the stray blank line that fails ruff format --check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Do not share a contact when a node has reported more than one key #184

A forged NODEINFO uplinked over MQTT could otherwise become the newest
packet, and the shared contact would then replace the key a scanning
radio already trusts. The QR endpoint now reads every stored NODEINFO
from the node and refuses to share when their 32-byte keys disagree.
It reports that in public_key_conflict, and the node page says why.

Add an index on packet (from_node_id, portnum, import_time_us DESC) so
the lookup reads only the node's NODEINFO packets. Order with NULLS
LAST so PostgreSQL does not treat old rows without import_time_us as
newest. Carry is_licensed and is_unmessagable into the contact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Skip NODEINFO rows with no payload when building the contact #184

packet.payload is nullable, and MeshPacket.FromString(None) raises
TypeError, so one such row made the QR endpoint answer 500.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:08:54 -07:00
35 changed files with 1057 additions and 295 deletions

No files matched your search

+143 -128
View File
@@ -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")
+8
View File
@@ -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.
+2 -2
View File
@@ -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():
+26 -1
View File
@@ -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
+3 -1
View File
@@ -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...",
+3 -1
View File
@@ -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": {
+3 -1
View File
@@ -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": "Загрузка информации о пакете...",
+7
View File
@@ -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
View File
@@ -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
View File
@@ -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
+5 -1
View File
@@ -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>&lt;redacted&gt;</code>.</p>
+6 -2
View File
@@ -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 &#39;http://localhost:8081/api/coverage/1234?mode=perimeter&amp;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">{
&quot;packets&quot;: [
+17
View File
@@ -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");
};
})();
+5 -1
View File
@@ -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>
+8 -4
View File
@@ -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 ---
+3 -2
View File
@@ -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)}`;
-1
View File
@@ -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';
+22
View File
@@ -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()
+2 -1
View File
@@ -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>
+2 -2
View File
@@ -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; }
+63 -28
View File
@@ -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";
+62 -25
View File
@@ -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();
}
+1 -1
View File
@@ -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>`
: ""
}
+15 -3
View File
@@ -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
View File
@@ -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">&times;</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: &copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, SRTM | Map style: &copy; <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 || [];
+4
View File
@@ -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); }
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
# -------------------------
+48
View File
@@ -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()
+44
View File
@@ -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()