Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
194658d303 | ||
|
|
e4483ec02a | ||
|
|
e1e34c120e | ||
|
|
63abac5448 | ||
|
|
65fb307d07 | ||
|
|
f5d5dd6049 | ||
|
|
8446ef8956 | ||
|
|
7c8f5c42d0 | ||
|
|
11b6a92f71 | ||
|
|
d022ec455e | ||
|
|
acba2284c5 | ||
|
|
dddc8e0a13 | ||
|
|
93fc31efd4 | ||
|
|
3e1d95ed2c | ||
|
|
ec9fafc362 | ||
|
|
0e424da10a | ||
|
|
7ad590a83c | ||
|
|
b80a0b27df | ||
|
|
330ee47932 | ||
|
|
29860e8e7d | ||
|
|
ef52e96d7c | ||
|
|
acfd71bd2e | ||
|
|
f570fdb65a | ||
|
|
11fb764fa8 | ||
|
|
c0c9a499d5 | ||
|
|
cfa71f9235 | ||
|
|
5f3d37bf06 | ||
|
|
1e9b1a3ce3 | ||
|
|
d66c5d21e9 | ||
|
|
a5f46d6360 | ||
|
|
ae7ba15263 |
No files matched your search
@@ -1,96 +1,75 @@
|
|||||||
# Running MeshView with Docker
|
# Running MeshView with Docker
|
||||||
|
|
||||||
MeshView container images are built automatically and published to GitHub Container Registry.
|
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
|
## Quick Start
|
||||||
|
|
||||||
Pull and run the latest image:
|
Prepare the configuration and persistent directories before starting the container:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
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}
|
mkdir -p meshview/{config,data,logs,backups}
|
||||||
|
|
||||||
# Copy sample config (first time only)
|
# First-time setup only: extract the image's bundled configuration.
|
||||||
docker run --rm ghcr.io/pablorevilla-meshtastic/meshview:latest \
|
# --entrypoint bypasses the normal MeshView startup command.
|
||||||
cat /etc/meshview/config.ini > meshview/config/config.ini
|
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
|
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 \
|
docker run -d \
|
||||||
--name meshview \
|
--name meshview \
|
||||||
--restart unless-stopped \
|
--restart unless-stopped \
|
||||||
-p 8081:8081 \
|
-p 8081:8081 \
|
||||||
-v $(pwd)/meshview/config:/etc/meshview \
|
-v "$(pwd)/meshview/config:/etc/meshview:ro" \
|
||||||
-v $(pwd)/meshview/data:/var/lib/meshview \
|
-v "$(pwd)/meshview/data:/var/lib/meshview" \
|
||||||
-v $(pwd)/meshview/logs:/var/log/meshview \
|
-v "$(pwd)/meshview/logs:/var/log/meshview" \
|
||||||
|
-v "$(pwd)/meshview/backups:/var/lib/meshview/backups" \
|
||||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
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
|
## 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
|
```yaml
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
meshview:
|
meshview:
|
||||||
image: ghcr.io/pablorevilla-meshtastic/meshview:latest
|
image: ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||||
@@ -99,45 +78,57 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- "8081:8081"
|
- "8081:8081"
|
||||||
volumes:
|
volumes:
|
||||||
- ./config:/etc/meshview
|
- ./config:/etc/meshview:ro
|
||||||
- ./data:/var/lib/meshview
|
- ./data:/var/lib/meshview
|
||||||
- ./logs:/var/log/meshview
|
- ./logs:/var/log/meshview
|
||||||
- ./backups:/var/lib/meshview/backups # For database backups
|
- ./backups:/var/lib/meshview/backups
|
||||||
environment:
|
environment:
|
||||||
- TZ=America/Los_Angeles # Set your timezone
|
TZ: America/Los_Angeles
|
||||||
```
|
```
|
||||||
|
|
||||||
Run with:
|
Start from that directory:
|
||||||
|
|
||||||
```bash
|
```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
|
## Configuration
|
||||||
|
|
||||||
### Minimum Configuration
|
Edit the extracted `config.ini` rather than replacing it with a partial configuration.
|
||||||
|
Keep the container server listening on all interfaces:
|
||||||
Edit your `config.ini` to configure MQTT connection:
|
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[mqtt]
|
[server]
|
||||||
server = mqtt.meshtastic.org
|
bind = 0.0.0.0
|
||||||
topics = ["msh/US/#"]
|
port = 8081
|
||||||
port = 1883
|
```
|
||||||
username =
|
|
||||||
password =
|
|
||||||
|
|
||||||
|
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]
|
[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
|
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
|
### Database Backups
|
||||||
|
|
||||||
To enable automatic daily backups inside the container:
|
Enable backups in the existing `[cleanup]` section:
|
||||||
|
|
||||||
```ini
|
```ini
|
||||||
[cleanup]
|
[cleanup]
|
||||||
@@ -147,99 +138,123 @@ backup_hour = 2
|
|||||||
backup_minute = 00
|
backup_minute = 00
|
||||||
```
|
```
|
||||||
|
|
||||||
Then mount the backups directory:
|
Both startup examples mount this directory to `meshview/backups` on the host. Backups
|
||||||
|
remain disabled until you enable them in the configuration.
|
||||||
```bash
|
|
||||||
-v $(pwd)/meshview/backups:/var/lib/meshview/backups
|
|
||||||
```
|
|
||||||
|
|
||||||
## Available Tags
|
## Available Tags
|
||||||
|
|
||||||
| Tag | Description |
|
| Tag | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| `latest` | Latest build from the main branch |
|
| `latest` | Latest build from the repository's default branch |
|
||||||
| `dev-v3` | Development branch |
|
| Branch name | Builds published for that branch |
|
||||||
| `v1.2.3` | Specific version tags |
|
| `3.0.6` | Example version tag; use a version published in the registry |
|
||||||
|
|
||||||
## Updating
|
## 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
|
```bash
|
||||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
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:
|
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
|
||||||
```bash
|
and merely restarting an existing container does not switch it to the new image.
|
||||||
docker-compose pull
|
|
||||||
docker-compose up -d
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
View container logs:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker logs meshview
|
docker logs meshview
|
||||||
|
|
||||||
# Follow logs
|
|
||||||
docker logs -f meshview
|
docker logs -f meshview
|
||||||
|
|
||||||
# Last 100 lines
|
|
||||||
docker logs --tail 100 meshview
|
docker logs --tail 100 meshview
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For Compose installations, `docker compose logs -f meshview` also works.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
|
|
||||||
### Container won't start
|
### Container won't start
|
||||||
|
|
||||||
Check logs:
|
Check `docker logs meshview`. Verify that the mounted `config` directory contains a
|
||||||
```bash
|
readable `config.ini`, and that the database and log directories are writable by UID 10001.
|
||||||
docker logs meshview
|
|
||||||
```
|
### 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
|
### Database permission issues
|
||||||
|
|
||||||
Ensure the data directory is writable:
|
From the parent directory containing `meshview`, repair writable directory ownership:
|
||||||
|
|
||||||
```bash
|
```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
|
### Can't connect to MQTT
|
||||||
|
|
||||||
1. Check your MQTT configuration in `config.ini`
|
Check the broker hostname, port, credentials, and topics in `config.ini`.
|
||||||
2. Verify network connectivity from the container:
|
Test DNS resolution and a TCP connection using Python, which is included in the image:
|
||||||
```bash
|
|
||||||
docker exec meshview ping mqtt.meshtastic.org
|
```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
|
### 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
|
```bash
|
||||||
-p 8082:8081
|
-p 8082:8081
|
||||||
```
|
```
|
||||||
|
|
||||||
Then access at: http://localhost:8082
|
For Compose, use `"8082:8081"` under `ports`. Access http://localhost:8082.
|
||||||
|
|
||||||
## Building Your Own Image
|
## Building Your Own Image
|
||||||
|
|
||||||
If you want to build from source:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/pablorevilla-meshtastic/meshview.git
|
git clone https://github.com/pablorevilla-meshtastic/meshview.git
|
||||||
cd meshview
|
cd meshview
|
||||||
docker build -f Containerfile -t meshview:local .
|
docker build -f Containerfile -t meshview:local .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use `meshview:local` instead of the registry image in the setup and run commands.
|
||||||
|
|
||||||
## Security Notes
|
## Security Notes
|
||||||
|
|
||||||
- The container runs as a non-root user (`app`, UID 10001)
|
- The container runs as a non-root user (`app`, UID 10001).
|
||||||
- No privileged access required
|
- No privileged access is required.
|
||||||
- Only port 8081 is exposed
|
- The examples publish port 8081 on the host; configuration is mounted read-only.
|
||||||
- All data stored in mounted volumes
|
- Database, file logs, and backups use the mounted directories shown above.
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""add last altitude to node
|
||||||
|
|
||||||
|
Revision ID: 44612e74a798
|
||||||
|
Revises: 4d2a7c9e1b30
|
||||||
|
Create Date: 2026-10-28 10:37:11.340792
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '44612e74a798'
|
||||||
|
down_revision: str | None = '4d2a7c9e1b30'
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table("node") as batch_op:
|
||||||
|
batch_op.add_column(sa.Column("last_altitude", sa.BigInteger(), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table("node") as batch_op:
|
||||||
|
batch_op.drop_column("last_altitude")
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Add packet index on from_node_id, portnum and import_time_us
|
||||||
|
|
||||||
|
Revision ID: 4d2a7c9e1b30
|
||||||
|
Revises: e8f2c4b6d9a1
|
||||||
|
Create Date: 2026-10-26 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "4d2a7c9e1b30"
|
||||||
|
down_revision: str | None = "e8f2c4b6d9a1"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
INDEX_NAME = "idx_packet_from_node_portnum_time_us"
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Lets the node QR endpoint read a node's NODEINFO packets without scanning all its packets.
|
||||||
|
inspector = sa.inspect(op.get_bind())
|
||||||
|
if INDEX_NAME not in {index["name"] for index in inspector.get_indexes("packet")}:
|
||||||
|
op.create_index(
|
||||||
|
INDEX_NAME,
|
||||||
|
"packet",
|
||||||
|
["from_node_id", "portnum", sa.text("import_time_us DESC")],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(INDEX_NAME, table_name="packet")
|
||||||
@@ -73,6 +73,7 @@ Response Example
|
|||||||
"role": "client",
|
"role": "client",
|
||||||
"last_lat": 377749000,
|
"last_lat": 377749000,
|
||||||
"last_long": -1224194000,
|
"last_long": -1224194000,
|
||||||
|
"last_altitude": 125,
|
||||||
"channel": "main",
|
"channel": "main",
|
||||||
"is_mqtt_gateway": true,
|
"is_mqtt_gateway": true,
|
||||||
"first_seen_us": 1736283723456789,
|
"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
|
## 2. Packets API
|
||||||
@@ -96,6 +103,7 @@ Query Parameters
|
|||||||
- `portnum` (optional, int): Filter by port number.
|
- `portnum` (optional, int): Filter by port number.
|
||||||
- `contains` (optional, string): Payload substring filter.
|
- `contains` (optional, string): Payload substring filter.
|
||||||
- `from_node_id` (optional, int): Filter by sender node ID.
|
- `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.
|
- `to_node_id` (optional, int): Filter by recipient node ID.
|
||||||
- `node_id` (optional, int): Legacy filter matching either from or to node ID.
|
- `node_id` (optional, int): Legacy filter matching either from or to node ID.
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
__version__ = "3.0.9"
|
__version__ = "4.0.0"
|
||||||
__release_date__ = "2026-11-12"
|
__release_date__ = "2026-11-01"
|
||||||
|
|
||||||
|
|
||||||
def get_git_revision():
|
def get_git_revision():
|
||||||
|
|||||||
@@ -17,18 +17,43 @@ def text_message(payload):
|
|||||||
return payload.decode("utf-8")
|
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 = {
|
DECODE_MAP = {
|
||||||
PortNum.POSITION_APP: Position.FromString,
|
PortNum.POSITION_APP: Position.FromString,
|
||||||
PortNum.NEIGHBORINFO_APP: NeighborInfo.FromString,
|
PortNum.NEIGHBORINFO_APP: NeighborInfo.FromString,
|
||||||
PortNum.NODEINFO_APP: User.FromString,
|
PortNum.NODEINFO_APP: User.FromString,
|
||||||
PortNum.TELEMETRY_APP: Telemetry.FromString,
|
PortNum.TELEMETRY_APP: Telemetry.FromString,
|
||||||
PortNum.TRACEROUTE_APP: RouteDiscovery.FromString,
|
PortNum.TRACEROUTE_APP: route_discovery,
|
||||||
PortNum.ROUTING_APP: Routing.FromString,
|
PortNum.ROUTING_APP: Routing.FromString,
|
||||||
PortNum.TEXT_MESSAGE_APP: text_message,
|
PortNum.TEXT_MESSAGE_APP: text_message,
|
||||||
PortNum.MAP_REPORT_APP: MapReport.FromString,
|
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):
|
def decode_payload(portnum, payload):
|
||||||
if portnum not in DECODE_MAP:
|
if portnum not in DECODE_MAP:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -246,7 +246,9 @@
|
|||||||
"share_contact_qr": "Share Contact QR",
|
"share_contact_qr": "Share Contact QR",
|
||||||
"copy_url": "Copy URL",
|
"copy_url": "Copy URL",
|
||||||
"copied": "Copied!",
|
"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": {
|
"packet": {
|
||||||
"loading": "Loading packet information...",
|
"loading": "Loading packet information...",
|
||||||
|
|||||||
@@ -232,7 +232,9 @@
|
|||||||
"share_contact_qr": "Compartir contacto QR",
|
"share_contact_qr": "Compartir contacto QR",
|
||||||
"copy_url": "Copiar URL",
|
"copy_url": "Copiar URL",
|
||||||
"copied": "¡Copiado!",
|
"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": {
|
"packet": {
|
||||||
|
|||||||
@@ -227,7 +227,9 @@
|
|||||||
"share_contact_qr": "Поделиться QR контакта",
|
"share_contact_qr": "Поделиться QR контакта",
|
||||||
"copy_url": "Копировать URL",
|
"copy_url": "Копировать URL",
|
||||||
"copied": "Скопировано!",
|
"copied": "Скопировано!",
|
||||||
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве."
|
"scan_qr_to_add": "Отсканируйте этот QR-код, чтобы добавить эту ноду в качестве контакта на другом устройстве.",
|
||||||
|
"qr_no_public_key": "Открытый ключ этой ноды ещё не получен, поэтому её нельзя отправить как контакт.",
|
||||||
|
"qr_key_conflict": "Эта нода сообщила более одного открытого ключа, поэтому она не отправляется как контакт."
|
||||||
},
|
},
|
||||||
"packet": {
|
"packet": {
|
||||||
"loading": "Загрузка информации о пакете...",
|
"loading": "Загрузка информации о пакете...",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class Node(Base):
|
|||||||
role: Mapped[str] = mapped_column(nullable=True)
|
role: Mapped[str] = mapped_column(nullable=True)
|
||||||
last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||||
last_long: 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)
|
channel: Mapped[str] = mapped_column(nullable=True)
|
||||||
is_mqtt_gateway: Mapped[bool] = mapped_column(nullable=True)
|
is_mqtt_gateway: Mapped[bool] = mapped_column(nullable=True)
|
||||||
first_seen_us: Mapped[int] = mapped_column(BigInteger, 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_to_node_id", "to_node_id"),
|
||||||
Index("idx_packet_import_time_us", desc("import_time_us")),
|
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_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"),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,9 +130,13 @@ def decrypt(packet, key):
|
|||||||
try:
|
try:
|
||||||
data = Data()
|
data = Data()
|
||||||
data.ParseFromString(raw_proto)
|
data.ParseFromString(raw_proto)
|
||||||
packet.decoded.CopyFrom(data)
|
|
||||||
except DecodeError:
|
except DecodeError:
|
||||||
return False
|
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
|
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:
|
if start_time is None:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
confirmed_secondary_keys = set()
|
||||||
async for msg in client.messages:
|
async for msg in client.messages:
|
||||||
try:
|
try:
|
||||||
envelope = ServiceEnvelope.FromString(msg.payload)
|
envelope = ServiceEnvelope.FromString(msg.payload)
|
||||||
except DecodeError:
|
except DecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for key in keyring:
|
was_encrypted = envelope.packet.HasField("encrypted")
|
||||||
|
for key_index, key in enumerate(keyring):
|
||||||
if decrypt(envelope.packet, key):
|
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
|
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:
|
if not envelope.packet.decoded:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from datetime import UTC, datetime, timedelta
|
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:
|
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:
|
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:
|
async with mqtt_database.async_session() as session:
|
||||||
try:
|
try:
|
||||||
hw_model = (
|
hw_model = decode_payload.enum_name(HardwareModel, map_report.hw_model)
|
||||||
HardwareModel.Name(map_report.hw_model)
|
role = decode_payload.enum_name(Config.DeviceConfig.Role, map_report.role)
|
||||||
if hasattr(HardwareModel, "Name")
|
|
||||||
else "unknown"
|
|
||||||
)
|
|
||||||
role = (
|
|
||||||
Config.DeviceConfig.Role.Name(map_report.role)
|
|
||||||
if hasattr(Config.DeviceConfig.Role, "Name")
|
|
||||||
else "unknown"
|
|
||||||
)
|
|
||||||
node = (
|
node = (
|
||||||
await session.execute(select(Node).where(Node.node_id == node_id))
|
await session.execute(select(Node).where(Node.node_id == node_id))
|
||||||
).scalar_one_or_none()
|
).scalar_one_or_none()
|
||||||
@@ -230,6 +224,9 @@ async def process_envelope(topic, env):
|
|||||||
if has_location:
|
if has_location:
|
||||||
node.last_lat = map_report.latitude_i
|
node.last_lat = map_report.latitude_i
|
||||||
node.last_long = map_report.longitude_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.firmware = map_report.firmware_version
|
||||||
node.last_seen_us = now_us
|
node.last_seen_us = now_us
|
||||||
if node.first_seen_us is None:
|
if node.first_seen_us is None:
|
||||||
@@ -250,6 +247,9 @@ async def process_envelope(topic, env):
|
|||||||
if has_location:
|
if has_location:
|
||||||
node.last_lat = map_report.latitude_i
|
node.last_lat = map_report.latitude_i
|
||||||
node.last_long = map_report.longitude_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)
|
session.add(node)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Error processing MAP_REPORT_APP: {e}")
|
print(f"Error processing MAP_REPORT_APP: {e}")
|
||||||
@@ -326,7 +326,8 @@ async def process_envelope(topic, env):
|
|||||||
"node_id": gateway_node_id,
|
"node_id": gateway_node_id,
|
||||||
"channel": env.channel_id,
|
"channel": env.channel_id,
|
||||||
"rx_time": env.packet.rx_time,
|
"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,
|
"rx_rssi": env.packet.rx_rssi,
|
||||||
"hop_limit": env.packet.hop_limit,
|
"hop_limit": env.packet.hop_limit,
|
||||||
"hop_start": env.packet.hop_start,
|
"hop_start": env.packet.hop_start,
|
||||||
@@ -374,16 +375,8 @@ async def process_envelope(topic, env):
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
return
|
return
|
||||||
|
|
||||||
hw_model = (
|
hw_model = decode_payload.enum_name(HardwareModel, user.hw_model)
|
||||||
HardwareModel.Name(user.hw_model)
|
role = decode_payload.enum_name(Config.DeviceConfig.Role, user.role)
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
node = (
|
node = (
|
||||||
await session.execute(select(Node).where(Node.id == user.id))
|
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)
|
now_us = int(time.time() * 1_000_000)
|
||||||
node.last_lat = position.latitude_i
|
node.last_lat = position.latitude_i
|
||||||
node.last_long = position.longitude_i
|
node.last_long = position.longitude_i
|
||||||
|
if position.HasField("altitude"):
|
||||||
|
node.last_altitude = position.altitude
|
||||||
node.last_seen_us = now_us
|
node.last_seen_us = now_us
|
||||||
if node.first_seen_us is None:
|
if node.first_seen_us is None:
|
||||||
node.first_seen_us = now_us
|
node.first_seen_us = now_us
|
||||||
|
|||||||
@@ -17,52 +17,56 @@ code { background:var(--mesh-soft); padding:3px 6px; border-radius:4px; overflow
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
--mesh-primary:#187346;
|
--mesh-primary:#245ea8;
|
||||||
--mesh-primary-dark:#0f5132;
|
--mesh-primary-dark:#19467f;
|
||||||
--mesh-primary-rgb:24,115,70;
|
--mesh-primary-rgb:36,94,168;
|
||||||
--mesh-brand:#187346;
|
--mesh-brand:#245ea8;
|
||||||
--mesh-brand-deep:#0f5132;
|
--mesh-brand-deep:#19467f;
|
||||||
--mesh-surface:#ffffff;
|
--mesh-surface:#ffffff;
|
||||||
--mesh-text:#243247;
|
--mesh-text:#243247;
|
||||||
--mesh-soft:#eaf5ee;
|
--mesh-soft:#eaf1fb;
|
||||||
--mesh-hover:#dceee2;
|
--mesh-hover:#dce8f8;
|
||||||
--mesh-border:#dce7df;
|
--mesh-border:#dce4ef;
|
||||||
--mesh-page:#f5f9f6;
|
--mesh-page:#f5f8fc;
|
||||||
--mesh-stripe:#f7faf8;
|
--mesh-stripe:#f7f9fc;
|
||||||
--mesh-muted:#64748b;
|
--mesh-muted:#64748b;
|
||||||
--mesh-chat-stripe:#eef6f0;
|
--mesh-chat-stripe:#eef3fb;
|
||||||
--mesh-pulse-soft:#edf8f0;
|
--mesh-pulse-soft:#edf4ff;
|
||||||
--mesh-pulse-peak:#ccebd7;
|
--mesh-pulse-peak:#c9defa;
|
||||||
--mesh-pulse-mid:#e2f3e8;
|
--mesh-pulse-mid:#deebfc;
|
||||||
}
|
}
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
color-scheme:dark;
|
color-scheme:dark;
|
||||||
--mesh-primary:#80cfa3;
|
--mesh-primary:#8bbcff;
|
||||||
--mesh-primary-dark:#a3e3be;
|
--mesh-primary-dark:#b6d5ff;
|
||||||
--mesh-primary-rgb:128,207,163;
|
--mesh-primary-rgb:139,188,255;
|
||||||
--mesh-brand:#14532d;
|
--mesh-brand:#193d69;
|
||||||
--mesh-brand-deep:#0c3a1f;
|
--mesh-brand-deep:#112b4b;
|
||||||
--mesh-surface:#18231e;
|
--mesh-surface:#182335;
|
||||||
--mesh-text:#e2ece5;
|
--mesh-text:#e2eaf6;
|
||||||
--mesh-soft:#203c2b;
|
--mesh-soft:#203653;
|
||||||
--mesh-hover:#294c36;
|
--mesh-hover:#29486c;
|
||||||
--mesh-border:#34483c;
|
--mesh-border:#34465f;
|
||||||
--mesh-page:#101914;
|
--mesh-page:#101925;
|
||||||
--mesh-stripe:#1c2a22;
|
--mesh-stripe:#1c2a3d;
|
||||||
--mesh-muted:#9faea6;
|
--mesh-muted:#a0aec2;
|
||||||
--mesh-chat-stripe:#203227;
|
--mesh-chat-stripe:#20314a;
|
||||||
--mesh-pulse-soft:#203c2b;
|
--mesh-pulse-soft:#203653;
|
||||||
--mesh-pulse-peak:#356b49;
|
--mesh-pulse-peak:#355d8e;
|
||||||
--mesh-pulse-mid:#294c36;
|
--mesh-pulse-mid:#29486c;
|
||||||
--bs-link-color:var(--mesh-primary);
|
--bs-link-color:var(--mesh-primary);
|
||||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||||
--bs-link-hover-color:var(--mesh-primary-dark);
|
--bs-link-hover-color:var(--mesh-primary-dark);
|
||||||
--bs-link-hover-color-rgb:163,227,190;
|
--bs-link-hover-color-rgb:182,213,255;
|
||||||
}
|
}
|
||||||
body { background:var(--mesh-page); color:var(--mesh-text); }
|
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-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-header .mesh-theme-toggle { margin-left:auto; }
|
||||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
.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; }
|
.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; } }
|
@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); }
|
[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 -->
|
<!-- mesh-theme:end -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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:#245ea8" 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>
|
<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>Chat uses <code>GET /api/packets?portnum=1</code>. There is no separate <code>/api/chat</code> endpoint.</p>
|
||||||
<p>Use <code>limit</code> and <code>since</code> for incremental polling. Direct port-1 text messages are returned as <code><redacted></code>.</p>
|
<p>Use <code>limit</code> and <code>since</code> for incremental polling. Direct port-1 text messages are returned as <code><redacted></code>.</p>
|
||||||
|
|||||||
@@ -41,52 +41,56 @@ footer { border-top:1px solid var(--border); padding:20px 24px; text-align:cente
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
--mesh-primary:#187346;
|
--mesh-primary:#245ea8;
|
||||||
--mesh-primary-dark:#0f5132;
|
--mesh-primary-dark:#19467f;
|
||||||
--mesh-primary-rgb:24,115,70;
|
--mesh-primary-rgb:36,94,168;
|
||||||
--mesh-brand:#187346;
|
--mesh-brand:#245ea8;
|
||||||
--mesh-brand-deep:#0f5132;
|
--mesh-brand-deep:#19467f;
|
||||||
--mesh-surface:#ffffff;
|
--mesh-surface:#ffffff;
|
||||||
--mesh-text:#243247;
|
--mesh-text:#243247;
|
||||||
--mesh-soft:#eaf5ee;
|
--mesh-soft:#eaf1fb;
|
||||||
--mesh-hover:#dceee2;
|
--mesh-hover:#dce8f8;
|
||||||
--mesh-border:#dce7df;
|
--mesh-border:#dce4ef;
|
||||||
--mesh-page:#f5f9f6;
|
--mesh-page:#f5f8fc;
|
||||||
--mesh-stripe:#f7faf8;
|
--mesh-stripe:#f7f9fc;
|
||||||
--mesh-muted:#64748b;
|
--mesh-muted:#64748b;
|
||||||
--mesh-chat-stripe:#eef6f0;
|
--mesh-chat-stripe:#eef3fb;
|
||||||
--mesh-pulse-soft:#edf8f0;
|
--mesh-pulse-soft:#edf4ff;
|
||||||
--mesh-pulse-peak:#ccebd7;
|
--mesh-pulse-peak:#c9defa;
|
||||||
--mesh-pulse-mid:#e2f3e8;
|
--mesh-pulse-mid:#deebfc;
|
||||||
}
|
}
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
color-scheme:dark;
|
color-scheme:dark;
|
||||||
--mesh-primary:#80cfa3;
|
--mesh-primary:#8bbcff;
|
||||||
--mesh-primary-dark:#a3e3be;
|
--mesh-primary-dark:#b6d5ff;
|
||||||
--mesh-primary-rgb:128,207,163;
|
--mesh-primary-rgb:139,188,255;
|
||||||
--mesh-brand:#14532d;
|
--mesh-brand:#193d69;
|
||||||
--mesh-brand-deep:#0c3a1f;
|
--mesh-brand-deep:#112b4b;
|
||||||
--mesh-surface:#18231e;
|
--mesh-surface:#182335;
|
||||||
--mesh-text:#e2ece5;
|
--mesh-text:#e2eaf6;
|
||||||
--mesh-soft:#203c2b;
|
--mesh-soft:#203653;
|
||||||
--mesh-hover:#294c36;
|
--mesh-hover:#29486c;
|
||||||
--mesh-border:#34483c;
|
--mesh-border:#34465f;
|
||||||
--mesh-page:#101914;
|
--mesh-page:#101925;
|
||||||
--mesh-stripe:#1c2a22;
|
--mesh-stripe:#1c2a3d;
|
||||||
--mesh-muted:#9faea6;
|
--mesh-muted:#a0aec2;
|
||||||
--mesh-chat-stripe:#203227;
|
--mesh-chat-stripe:#20314a;
|
||||||
--mesh-pulse-soft:#203c2b;
|
--mesh-pulse-soft:#203653;
|
||||||
--mesh-pulse-peak:#356b49;
|
--mesh-pulse-peak:#355d8e;
|
||||||
--mesh-pulse-mid:#294c36;
|
--mesh-pulse-mid:#29486c;
|
||||||
--bs-link-color:var(--mesh-primary);
|
--bs-link-color:var(--mesh-primary);
|
||||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||||
--bs-link-hover-color:var(--mesh-primary-dark);
|
--bs-link-hover-color:var(--mesh-primary-dark);
|
||||||
--bs-link-hover-color-rgb:163,227,190;
|
--bs-link-hover-color-rgb:182,213,255;
|
||||||
}
|
}
|
||||||
body { background:var(--mesh-page); color:var(--mesh-text); }
|
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-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-header .mesh-theme-toggle { margin-left:auto; }
|
||||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
.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; }
|
.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; } }
|
@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); }
|
[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>
|
</head>
|
||||||
<body>
|
<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>
|
<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>
|
<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>
|
<h1 id="api-documentation">API Documentation</h1>
|
||||||
@@ -157,7 +161,7 @@ curl 'http://localhost:8081/api/coverage/1234?mode=perimeter&radius_km=2
|
|||||||
<section class="endpoint" id="get-api-packets"><h3><span class="method">GET</span> <code>/api/packets</code></h3>
|
<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>Returns packets with optional filters.</p>
|
||||||
<p>Query Parameters</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>
|
<p>Response Example</p>
|
||||||
<pre><code class="language-json">{
|
<pre><code class="language-json">{
|
||||||
"packets": [
|
"packets": [
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 79 KiB |
@@ -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");
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 172 KiB |
@@ -24,52 +24,56 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
--mesh-primary:#187346;
|
--mesh-primary:#245ea8;
|
||||||
--mesh-primary-dark:#0f5132;
|
--mesh-primary-dark:#19467f;
|
||||||
--mesh-primary-rgb:24,115,70;
|
--mesh-primary-rgb:36,94,168;
|
||||||
--mesh-brand:#187346;
|
--mesh-brand:#245ea8;
|
||||||
--mesh-brand-deep:#0f5132;
|
--mesh-brand-deep:#19467f;
|
||||||
--mesh-surface:#ffffff;
|
--mesh-surface:#ffffff;
|
||||||
--mesh-text:#243247;
|
--mesh-text:#243247;
|
||||||
--mesh-soft:#eaf5ee;
|
--mesh-soft:#eaf1fb;
|
||||||
--mesh-hover:#dceee2;
|
--mesh-hover:#dce8f8;
|
||||||
--mesh-border:#dce7df;
|
--mesh-border:#dce4ef;
|
||||||
--mesh-page:#f5f9f6;
|
--mesh-page:#f5f8fc;
|
||||||
--mesh-stripe:#f7faf8;
|
--mesh-stripe:#f7f9fc;
|
||||||
--mesh-muted:#64748b;
|
--mesh-muted:#64748b;
|
||||||
--mesh-chat-stripe:#eef6f0;
|
--mesh-chat-stripe:#eef3fb;
|
||||||
--mesh-pulse-soft:#edf8f0;
|
--mesh-pulse-soft:#edf4ff;
|
||||||
--mesh-pulse-peak:#ccebd7;
|
--mesh-pulse-peak:#c9defa;
|
||||||
--mesh-pulse-mid:#e2f3e8;
|
--mesh-pulse-mid:#deebfc;
|
||||||
}
|
}
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
color-scheme:dark;
|
color-scheme:dark;
|
||||||
--mesh-primary:#80cfa3;
|
--mesh-primary:#8bbcff;
|
||||||
--mesh-primary-dark:#a3e3be;
|
--mesh-primary-dark:#b6d5ff;
|
||||||
--mesh-primary-rgb:128,207,163;
|
--mesh-primary-rgb:139,188,255;
|
||||||
--mesh-brand:#14532d;
|
--mesh-brand:#193d69;
|
||||||
--mesh-brand-deep:#0c3a1f;
|
--mesh-brand-deep:#112b4b;
|
||||||
--mesh-surface:#18231e;
|
--mesh-surface:#182335;
|
||||||
--mesh-text:#e2ece5;
|
--mesh-text:#e2eaf6;
|
||||||
--mesh-soft:#203c2b;
|
--mesh-soft:#203653;
|
||||||
--mesh-hover:#294c36;
|
--mesh-hover:#29486c;
|
||||||
--mesh-border:#34483c;
|
--mesh-border:#34465f;
|
||||||
--mesh-page:#101914;
|
--mesh-page:#101925;
|
||||||
--mesh-stripe:#1c2a22;
|
--mesh-stripe:#1c2a3d;
|
||||||
--mesh-muted:#9faea6;
|
--mesh-muted:#a0aec2;
|
||||||
--mesh-chat-stripe:#203227;
|
--mesh-chat-stripe:#20314a;
|
||||||
--mesh-pulse-soft:#203c2b;
|
--mesh-pulse-soft:#203653;
|
||||||
--mesh-pulse-peak:#356b49;
|
--mesh-pulse-peak:#355d8e;
|
||||||
--mesh-pulse-mid:#294c36;
|
--mesh-pulse-mid:#29486c;
|
||||||
--bs-link-color:var(--mesh-primary);
|
--bs-link-color:var(--mesh-primary);
|
||||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||||
--bs-link-hover-color:var(--mesh-primary-dark);
|
--bs-link-hover-color:var(--mesh-primary-dark);
|
||||||
--bs-link-hover-color-rgb:163,227,190;
|
--bs-link-hover-color-rgb:182,213,255;
|
||||||
}
|
}
|
||||||
body { background:var(--mesh-page); color:var(--mesh-text); }
|
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-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-header .mesh-theme-toggle { margin-left:auto; }
|
||||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
.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; }
|
.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; } }
|
@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); }
|
[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 -->
|
<!-- mesh-theme:end -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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:#245ea8" 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="map"></div>
|
||||||
<div id="legend"></div>
|
<div id="legend"></div>
|
||||||
|
|
||||||
|
|||||||
@@ -38,52 +38,56 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
--mesh-primary:#187346;
|
--mesh-primary:#245ea8;
|
||||||
--mesh-primary-dark:#0f5132;
|
--mesh-primary-dark:#19467f;
|
||||||
--mesh-primary-rgb:24,115,70;
|
--mesh-primary-rgb:36,94,168;
|
||||||
--mesh-brand:#187346;
|
--mesh-brand:#245ea8;
|
||||||
--mesh-brand-deep:#0f5132;
|
--mesh-brand-deep:#19467f;
|
||||||
--mesh-surface:#ffffff;
|
--mesh-surface:#ffffff;
|
||||||
--mesh-text:#243247;
|
--mesh-text:#243247;
|
||||||
--mesh-soft:#eaf5ee;
|
--mesh-soft:#eaf1fb;
|
||||||
--mesh-hover:#dceee2;
|
--mesh-hover:#dce8f8;
|
||||||
--mesh-border:#dce7df;
|
--mesh-border:#dce4ef;
|
||||||
--mesh-page:#f5f9f6;
|
--mesh-page:#f5f8fc;
|
||||||
--mesh-stripe:#f7faf8;
|
--mesh-stripe:#f7f9fc;
|
||||||
--mesh-muted:#64748b;
|
--mesh-muted:#64748b;
|
||||||
--mesh-chat-stripe:#eef6f0;
|
--mesh-chat-stripe:#eef3fb;
|
||||||
--mesh-pulse-soft:#edf8f0;
|
--mesh-pulse-soft:#edf4ff;
|
||||||
--mesh-pulse-peak:#ccebd7;
|
--mesh-pulse-peak:#c9defa;
|
||||||
--mesh-pulse-mid:#e2f3e8;
|
--mesh-pulse-mid:#deebfc;
|
||||||
}
|
}
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
color-scheme:dark;
|
color-scheme:dark;
|
||||||
--mesh-primary:#80cfa3;
|
--mesh-primary:#8bbcff;
|
||||||
--mesh-primary-dark:#a3e3be;
|
--mesh-primary-dark:#b6d5ff;
|
||||||
--mesh-primary-rgb:128,207,163;
|
--mesh-primary-rgb:139,188,255;
|
||||||
--mesh-brand:#14532d;
|
--mesh-brand:#193d69;
|
||||||
--mesh-brand-deep:#0c3a1f;
|
--mesh-brand-deep:#112b4b;
|
||||||
--mesh-surface:#18231e;
|
--mesh-surface:#182335;
|
||||||
--mesh-text:#e2ece5;
|
--mesh-text:#e2eaf6;
|
||||||
--mesh-soft:#203c2b;
|
--mesh-soft:#203653;
|
||||||
--mesh-hover:#294c36;
|
--mesh-hover:#29486c;
|
||||||
--mesh-border:#34483c;
|
--mesh-border:#34465f;
|
||||||
--mesh-page:#101914;
|
--mesh-page:#101925;
|
||||||
--mesh-stripe:#1c2a22;
|
--mesh-stripe:#1c2a3d;
|
||||||
--mesh-muted:#9faea6;
|
--mesh-muted:#a0aec2;
|
||||||
--mesh-chat-stripe:#203227;
|
--mesh-chat-stripe:#20314a;
|
||||||
--mesh-pulse-soft:#203c2b;
|
--mesh-pulse-soft:#203653;
|
||||||
--mesh-pulse-peak:#356b49;
|
--mesh-pulse-peak:#355d8e;
|
||||||
--mesh-pulse-mid:#294c36;
|
--mesh-pulse-mid:#29486c;
|
||||||
--bs-link-color:var(--mesh-primary);
|
--bs-link-color:var(--mesh-primary);
|
||||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||||
--bs-link-hover-color:var(--mesh-primary-dark);
|
--bs-link-hover-color:var(--mesh-primary-dark);
|
||||||
--bs-link-hover-color-rgb:163,227,190;
|
--bs-link-hover-color-rgb:182,213,255;
|
||||||
}
|
}
|
||||||
body { background:var(--mesh-page); color:var(--mesh-text); }
|
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-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-header .mesh-theme-toggle { margin-left:auto; }
|
||||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
.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; }
|
.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; } }
|
@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); }
|
[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 -->
|
<!-- mesh-theme:end -->
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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:#245ea8" 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="map"></div>
|
||||||
|
|
||||||
@@ -168,7 +172,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
|||||||
nodes = data.nodes || [];
|
nodes = data.nodes || [];
|
||||||
} catch(err){ console.error('Failed to load nodes', err); }
|
} 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 markerById = {}; // Keyed by numeric node_id for packets
|
||||||
const nodeMap = new Map(); // Keyed by numeric node_id
|
const nodeMap = new Map(); // Keyed by numeric node_id
|
||||||
const channels = new Set();
|
const channels = new Set();
|
||||||
@@ -203,8 +207,8 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
|||||||
setTimeout(()=>marker.closePopup(),3000);
|
setTimeout(()=>marker.closePopup(),3000);
|
||||||
});
|
});
|
||||||
|
|
||||||
if(!markers[node.channel]) markers[node.channel]=[];
|
if(!markers.has(node.channel)) markers.set(node.channel, []);
|
||||||
markers[node.channel].push({marker,isRouter});
|
markers.get(node.channel).push({marker,isRouter});
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Filters ---
|
// --- Filters ---
|
||||||
|
|||||||
@@ -86,8 +86,9 @@
|
|||||||
if (!recentPackets.length) { note(container, 'No recent packets found.'); return; }
|
if (!recentPackets.length) { note(container, 'No recent packets found.'); return; }
|
||||||
container.replaceChildren();
|
container.replaceChildren();
|
||||||
for (const packet of recentPackets) {
|
for (const packet of recentPackets) {
|
||||||
const button = document.createElement('a');
|
const hasPacketLink = Number(packet.portnum) !== 73 && Number(packetId(packet)) > 0;
|
||||||
button.href = `/packet/${encodeURIComponent(packetId(packet))}`;
|
const button = document.createElement(hasPacketLink ? 'a' : 'div');
|
||||||
|
if (hasPacketLink) button.href = `/packet/${encodeURIComponent(packetId(packet))}`;
|
||||||
button.className = 'mesh-inspector-packet';
|
button.className = 'mesh-inspector-packet';
|
||||||
const heading = document.createElement('strong');
|
const heading = document.createElement('strong');
|
||||||
heading.textContent = `${portName(packet)} · ${timeText(packet.import_time_us)}`;
|
heading.textContent = `${portName(packet)} · ${timeText(packet.import_time_us)}`;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-labelledby="title">
|
||||||
<title id="title">Meshview: a mesh network through a magnifying glass</title>
|
<title id="title">Meshview: a mesh network through a magnifying glass</title>
|
||||||
<rect width="64" height="64" rx="15" fill="#eaf5ee"/>
|
<rect width="64" height="64" rx="15" fill="#eaf1fb"/>
|
||||||
<path d="M43 43 55 55" fill="none" stroke="#187346" stroke-width="7" stroke-linecap="round"/>
|
<path d="M43 43 55 55" fill="none" stroke="#245ea8" stroke-width="7" stroke-linecap="round"/>
|
||||||
<circle cx="28" cy="28" r="19" fill="none" stroke="#187346" stroke-width="4"/>
|
<circle cx="28" cy="28" r="19" fill="none" stroke="#245ea8" stroke-width="4"/>
|
||||||
<path d="m17 29 12-11 9 14-15 6-6-9 21 3" fill="none" stroke="#369361" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="m17 29 12-11 9 14-15 6-6-9 21 3" fill="none" stroke="#5287cb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<g fill="#187346" stroke="#eaf5ee" stroke-width="1.5">
|
<g fill="#245ea8" stroke="#eaf1fb" stroke-width="1.5">
|
||||||
<circle cx="17" cy="29" r="3.8"/>
|
<circle cx="17" cy="29" r="3.8"/>
|
||||||
<circle cx="29" cy="18" r="3.8"/>
|
<circle cx="29" cy="18" r="3.8"/>
|
||||||
<circle cx="38" cy="32" r="3.8"/>
|
<circle cx="38" cy="32" r="3.8"/>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 796 B After Width: | Height: | Size: 796 B |
@@ -1,17 +1,17 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="396" height="88" viewBox="0 0 396 88" role="img" aria-labelledby="title desc">
|
<svg xmlns="http://www.w3.org/2000/svg" width="396" height="88" viewBox="0 0 396 88" role="img" aria-labelledby="title desc">
|
||||||
<title id="title">Meshview</title>
|
<title id="title">Meshview</title>
|
||||||
<desc id="desc">Connected mesh nodes inside a green magnifying glass, alongside the Meshview wordmark.</desc>
|
<desc id="desc">Connected mesh nodes inside a blue magnifying glass, alongside the Meshview wordmark.</desc>
|
||||||
<g transform="translate(4 8) scale(1.125)">
|
<g transform="translate(4 8) scale(1.125)">
|
||||||
<rect width="64" height="64" rx="15" fill="#eaf5ee"/>
|
<rect width="64" height="64" rx="15" fill="#eaf1fb"/>
|
||||||
<path d="M43 43 55 55" fill="none" stroke="#187346" stroke-width="7" stroke-linecap="round"/>
|
<path d="M43 43 55 55" fill="none" stroke="#245ea8" stroke-width="7" stroke-linecap="round"/>
|
||||||
<circle cx="28" cy="28" r="19" fill="none" stroke="#187346" stroke-width="4"/>
|
<circle cx="28" cy="28" r="19" fill="none" stroke="#245ea8" stroke-width="4"/>
|
||||||
<path d="m17 29 12-11 9 14-15 6-6-9 21 3" fill="none" stroke="#369361" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="m17 29 12-11 9 14-15 6-6-9 21 3" fill="none" stroke="#5287cb" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
<g fill="#187346" stroke="#eaf5ee" stroke-width="1.5">
|
<g fill="#245ea8" stroke="#eaf1fb" stroke-width="1.5">
|
||||||
<circle cx="17" cy="29" r="3.8"/>
|
<circle cx="17" cy="29" r="3.8"/>
|
||||||
<circle cx="29" cy="18" r="3.8"/>
|
<circle cx="29" cy="18" r="3.8"/>
|
||||||
<circle cx="38" cy="32" r="3.8"/>
|
<circle cx="38" cy="32" r="3.8"/>
|
||||||
<circle cx="23" cy="38" r="3.8"/>
|
<circle cx="23" cy="38" r="3.8"/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
<text x="94" y="61" fill="#143b29" font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="51" font-weight="700" letter-spacing="-1.8">Meshview</text>
|
<text x="94" y="61" fill="#193d69" font-family="Inter, system-ui, -apple-system, 'Segoe UI', sans-serif" font-size="51" font-weight="700" letter-spacing="-1.8">Meshview</text>
|
||||||
</svg>
|
</svg>
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -20,9 +20,9 @@
|
|||||||
// Neutral chart colors follow the theme; packet/route/series colors stay intact.
|
// Neutral chart colors follow the theme; packet/route/series colors stay intact.
|
||||||
function chartOption(option) {
|
function chartOption(option) {
|
||||||
const palette = colors();
|
const palette = colors();
|
||||||
const text = new Set(['#333','#334155','#243247','#26364a','#e7eef7','#e2ece5']);
|
const text = new Set(['#333','#334155','#243247','#26364a','#e7eef7','#e2ece5','#e2eaf6']);
|
||||||
const muted = new Set(['#ccc','#64748b','#888','#9faea6','#8190a5']);
|
const muted = new Set(['#ccc','#64748b','#888','#9faea6','#8190a5','#a0aec2']);
|
||||||
const border = new Set(['#aaa','#c3cfdf','#34483c','#dce7df']);
|
const border = new Set(['#aaa','#c3cfdf','#34483c','#dce7df','#dce4ef','#34465f']);
|
||||||
function visit(value, key) {
|
function visit(value, key) {
|
||||||
if (Array.isArray(value)) return value.map(item => visit(item,key));
|
if (Array.isArray(value)) return value.map(item => visit(item,key));
|
||||||
if (value && Object.getPrototypeOf(value) === Object.prototype) {
|
if (value && Object.getPrototypeOf(value) === Object.prototype) {
|
||||||
@@ -30,8 +30,8 @@
|
|||||||
}
|
}
|
||||||
if (typeof value !== 'string') return value;
|
if (typeof value !== 'string') return value;
|
||||||
const hex = value.toLowerCase();
|
const hex = value.toLowerCase();
|
||||||
if (key === 'backgroundColor' && ['#fff','#ffffff','#18231e'].includes(hex)) return palette.surface;
|
if (key === 'backgroundColor' && ['#fff','#ffffff','#18231e','#182335'].includes(hex)) return palette.surface;
|
||||||
if (['borderColor','textBorderColor'].includes(key) && ['#fff','#ffffff','#000','#18231e'].includes(hex)) return palette.surface;
|
if (['borderColor','textBorderColor'].includes(key) && ['#fff','#ffffff','#000','#18231e','#182335'].includes(hex)) return palette.surface;
|
||||||
if (key === 'color' && text.has(hex)) return palette.text;
|
if (key === 'color' && text.has(hex)) return palette.text;
|
||||||
if (key === 'color' && muted.has(hex)) return palette.muted;
|
if (key === 'color' && muted.has(hex)) return palette.muted;
|
||||||
if (key === 'color' && border.has(hex)) return palette.border;
|
if (key === 'color' && border.has(hex)) return palette.border;
|
||||||
@@ -57,7 +57,6 @@
|
|||||||
function updateButton() {
|
function updateButton() {
|
||||||
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
document.querySelectorAll('[data-theme-toggle]').forEach(button => {
|
||||||
const dark = root.dataset.bsTheme === 'dark';
|
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-label',dark ? 'Switch to day mode' : 'Switch to night mode');
|
||||||
button.setAttribute('aria-pressed',String(dark));
|
button.setAttribute('aria-pressed',String(dark));
|
||||||
button.title = dark ? 'Day mode' : 'Night mode';
|
button.title = dark ? 'Day mode' : 'Night mode';
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ async def get_packets(
|
|||||||
after=None,
|
after=None,
|
||||||
contains=None, # substring search
|
contains=None, # substring search
|
||||||
limit=50,
|
limit=50,
|
||||||
|
channel=None,
|
||||||
):
|
):
|
||||||
async with database.async_session() as session:
|
async with database.async_session() as session:
|
||||||
stmt = select(models.Packet)
|
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
|
# Port filter
|
||||||
if portnum is not None:
|
if portnum is not None:
|
||||||
conditions.append(models.Packet.portnum == portnum)
|
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()
|
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 def get_packet(packet_id):
|
||||||
async with database.async_session() as session:
|
async with database.async_session() as session:
|
||||||
q = select(Packet).where(Packet.id == packet_id)
|
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 def get_traceroute(packet_id):
|
||||||
async with database.async_session() as session:
|
async with database.async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Traceroute)
|
select(Traceroute)
|
||||||
.where(Traceroute.packet_id == packet_id)
|
.where(Traceroute.packet_id == packet_id)
|
||||||
.order_by(Traceroute.import_time_us)
|
.order_by(Traceroute.import_time_us)
|
||||||
|
.limit(MAX_TRACEROUTE_ROWS)
|
||||||
)
|
)
|
||||||
return result.scalars()
|
return result.scalars()
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
|
<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="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
|
||||||
<script src="/static/html-escape.js"></script>
|
<script src="/static/html-escape.js"></script>
|
||||||
|
<script src="/static/csv.js"></script>
|
||||||
<script src="/static/packet-arrival.js"></script>
|
<script src="/static/packet-arrival.js"></script>
|
||||||
|
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
@@ -58,10 +59,10 @@
|
|||||||
--bs-link-color:var(--mesh-primary);
|
--bs-link-color:var(--mesh-primary);
|
||||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||||
--bs-link-hover-color:var(--mesh-primary-dark);
|
--bs-link-hover-color:var(--mesh-primary-dark);
|
||||||
--bs-link-hover-color-rgb:15,81,50;
|
--bs-link-hover-color-rgb:25,70,127;
|
||||||
--bs-focus-ring-color:rgba(var(--mesh-primary-rgb),.25);
|
--bs-focus-ring-color:rgba(var(--mesh-primary-rgb),.25);
|
||||||
}
|
}
|
||||||
[data-bs-theme="dark"] { --bs-link-color:#75c99c; --bs-link-color-rgb:117,201,156; --bs-link-hover-color:#a3dfbc; --bs-link-hover-color-rgb:163,223,188; }
|
[data-bs-theme="dark"] { --bs-link-color:#8bbcff; --bs-link-color-rgb:139,188,255; --bs-link-hover-color:#b6d5ff; --bs-link-hover-color-rgb:182,213,255; }
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
--bs-btn-bg:var(--mesh-brand); --bs-btn-border-color:var(--mesh-brand);
|
--bs-btn-bg:var(--mesh-brand); --bs-btn-border-color:var(--mesh-brand);
|
||||||
--bs-btn-hover-bg:var(--mesh-brand-deep); --bs-btn-hover-border-color:var(--mesh-brand-deep);
|
--bs-btn-hover-bg:var(--mesh-brand-deep); --bs-btn-hover-border-color:var(--mesh-brand-deep);
|
||||||
@@ -99,13 +100,13 @@ input[type="checkbox"],input[type="radio"] { accent-color:var(--mesh-primary); }
|
|||||||
@media(prefers-reduced-motion:reduce) {
|
@media(prefers-reduced-motion:reduce) {
|
||||||
.packet-arrival-glow > td { animation:none; background:var(--mesh-soft); }
|
.packet-arrival-glow > td { animation:none; background:var(--mesh-soft); }
|
||||||
}
|
}
|
||||||
.mesh-header { background:var(--mesh-brand); color:white; box-shadow:0 2px 8px #174b2d18; }
|
.mesh-header { background:var(--mesh-brand); color:white; box-shadow:0 2px 8px #193d6918; }
|
||||||
.mesh-header-inner { max-width:1600px; margin:auto; padding:0 28px; min-height:56px; display:flex; align-items:center; gap:32px; }
|
.mesh-header-inner { max-width:1600px; margin:auto; padding:0 28px; min-height:56px; display:flex; align-items:center; gap:32px; }
|
||||||
.mesh-brand { display:flex; align-items:center; gap:10px; font-size:19px; font-weight:700; color:white; text-decoration:none; flex-shrink:0; }
|
.mesh-brand { display:flex; align-items:center; gap:10px; font-size:19px; font-weight:700; color:white; text-decoration:none; flex-shrink:0; }
|
||||||
.mesh-brand:hover { color:white; }
|
.mesh-brand:hover { color:white; }
|
||||||
.mesh-brand-icon { width:32px; height:32px; display:block; flex-shrink:0; }
|
.mesh-brand-icon { width:32px; height:32px; display:block; flex-shrink:0; }
|
||||||
#site-menu { display:flex; align-items:center; flex-wrap:wrap; gap:4px; }
|
#site-menu { display:flex; align-items:center; flex-wrap:wrap; gap:4px; }
|
||||||
#site-menu a { color:#e6f4ea; text-decoration:none; padding:10px 12px; border-radius:6px; font-size:14px; text-transform:capitalize; }
|
#site-menu a { color:#e6effb; text-decoration:none; padding:10px 12px; border-radius:6px; font-size:14px; text-transform:capitalize; }
|
||||||
#site-menu a:hover, #site-menu a.active { color:white; background:#ffffff26; }
|
#site-menu a:hover, #site-menu a.active { color:white; background:#ffffff26; }
|
||||||
.mesh-menu-toggle { display:none; color:white; background:none; border:1px solid #ffffff70; border-radius:5px; font-size:22px; }
|
.mesh-menu-toggle { display:none; color:white; background:none; border:1px solid #ffffff70; border-radius:5px; font-size:22px; }
|
||||||
.mesh-content { max-width:1600px; margin:0 auto; padding:28px; }
|
.mesh-content { max-width:1600px; margin:0 auto; padding:28px; }
|
||||||
@@ -122,8 +123,8 @@ input[type="checkbox"],input[type="radio"] { accent-color:var(--mesh-primary); }
|
|||||||
.mesh-metric { padding:20px 24px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; box-shadow:0 2px 4px var(--mesh-text)04; display:flex; flex-direction:column; gap:5px; }
|
.mesh-metric { padding:20px 24px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; box-shadow:0 2px 4px var(--mesh-text)04; display:flex; flex-direction:column; gap:5px; }
|
||||||
.mesh-metric>span { font-size:13px; font-weight:500; color:var(--mesh-muted); }
|
.mesh-metric>span { font-size:13px; font-weight:500; color:var(--mesh-muted); }
|
||||||
.mesh-metric strong { font-size:30px; font-weight:650; color:var(--mesh-primary); font-variant-numeric:tabular-nums; }
|
.mesh-metric strong { font-size:30px; font-weight:650; color:var(--mesh-primary); font-variant-numeric:tabular-nums; }
|
||||||
.mesh-metric:nth-child(2) strong { color:#198754; }
|
.mesh-metric:nth-child(2) strong { color:var(--mesh-primary); }
|
||||||
.mesh-metric:nth-child(3) strong { color:#397955; }
|
.mesh-metric:nth-child(3) strong { color:var(--mesh-primary); }
|
||||||
.mesh-metric small { font-size:11px; color:var(--mesh-muted); }
|
.mesh-metric small { font-size:11px; color:var(--mesh-muted); }
|
||||||
.mesh-explorer { display:grid; grid-template-columns:290px minmax(0,1fr); gap:20px; }
|
.mesh-explorer { display:grid; grid-template-columns:290px minmax(0,1fr); gap:20px; }
|
||||||
.mesh-node-panel,.mesh-map-panel { background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; overflow:hidden; min-width:0; }
|
.mesh-node-panel,.mesh-map-panel { background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; overflow:hidden; min-width:0; }
|
||||||
@@ -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>
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div id="site-message" class="mesh-announcement"></div>
|
<div id="site-message" class="mesh-announcement"></div>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ body:has(#chat-container) .mesh-announcement { flex-shrink:0; }
|
|||||||
.chat-filter { width:170px; font-size:12px; border-color:var(--mesh-border); }
|
.chat-filter { width:170px; font-size:12px; border-color:var(--mesh-border); }
|
||||||
.chat-search { width:210px; font-size:12px; border-color:var(--mesh-border); }
|
.chat-search { width:210px; font-size:12px; border-color:var(--mesh-border); }
|
||||||
.chat-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
|
.chat-actions { display:flex; align-items:center; gap:8px; margin-left:auto; }
|
||||||
.chat-status { border-radius:20px; padding:4px 9px; background:#e8f5ee; color:#187346; font-size:11px; white-space:nowrap; }
|
.chat-status { border-radius:20px; padding:4px 9px; background:var(--mesh-soft); color:var(--mesh-primary); font-size:11px; white-space:nowrap; }
|
||||||
.chat-status[data-state="error"] { background:#fff0ed; color:#b23d2f; }
|
.chat-status[data-state="error"] { background:#fff0ed; color:#b23d2f; }
|
||||||
.chat-status[data-state="paused"],.chat-status[data-state="loading"] { background:#eef2f7; color:var(--mesh-muted); }
|
.chat-status[data-state="paused"],.chat-status[data-state="loading"] { background:#eef2f7; color:var(--mesh-muted); }
|
||||||
.chat-control { font-size:12px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:5px; padding:5px 9px; color:var(--mesh-text); }
|
.chat-control { font-size:12px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:5px; padding:5px 9px; color:var(--mesh-text); }
|
||||||
@@ -33,8 +33,8 @@ body:has(#chat-container) .mesh-announcement { flex-shrink:0; }
|
|||||||
.chat-message { min-width:0; color:var(--mesh-text); overflow-wrap:anywhere; white-space:pre-wrap; }
|
.chat-message { min-width:0; color:var(--mesh-text); overflow-wrap:anywhere; white-space:pre-wrap; }
|
||||||
.chat-meta { display:flex; align-items:center; gap:10px; font:11px system-ui,sans-serif; }
|
.chat-meta { display:flex; align-items:center; gap:10px; font:11px system-ui,sans-serif; }
|
||||||
.chat-channel { background:#edf1f7; color:var(--mesh-muted); border-radius:4px; padding:2px 6px; max-width:130px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
.chat-channel { background:#edf1f7; color:var(--mesh-muted); border-radius:4px; padding:2px 6px; max-width:130px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
.chat-details { display:inline-flex; align-items:center; padding:4px 8px; border:1px solid #9dceb0; border-radius:5px; background:#e8f5ee; color:#187346; font-weight:600; text-decoration:none; white-space:nowrap; }
|
.chat-details { display:inline-flex; align-items:center; padding:4px 8px; border:1px solid var(--mesh-border); border-radius:5px; background:var(--mesh-soft); color:var(--mesh-primary); font-weight:600; text-decoration:none; white-space:nowrap; }
|
||||||
.chat-details:hover { color:#0f5132; background:#d1e7dd; border-color:#69aa85; }
|
.chat-details:hover { color:var(--mesh-primary-dark); background:var(--mesh-hover); border-color:var(--mesh-primary); }
|
||||||
.chat-details:focus-visible { outline:2px solid #198754; outline-offset:2px; }
|
.chat-details:focus-visible { outline:2px solid #198754; outline-offset:2px; }
|
||||||
.replying-to { display:block; border-left:2px solid var(--mesh-border); margin:4px 0; padding:2px 9px; color:var(--mesh-muted); font-size:11px; white-space:normal; }
|
.replying-to { display:block; border-left:2px solid var(--mesh-border); margin:4px 0; padding:2px 9px; color:var(--mesh-muted); font-size:11px; white-space:normal; }
|
||||||
.chat-empty { text-align:center; color:var(--mesh-muted); padding:48px 20px; font:13px system-ui,sans-serif; }
|
.chat-empty { text-align:center; color:var(--mesh-muted); padding:48px 20px; font:13px system-ui,sans-serif; }
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
.mesh-inspector-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
|
.mesh-inspector-actions { display:flex; flex-wrap:wrap; gap:8px; margin-top:14px; }
|
||||||
.mesh-inspector-actions a { font-size:12px; }
|
.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 { 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 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-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; }
|
.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-tools { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||||
.mesh-traffic-status { font-size:11px; color:var(--mesh-primary); }
|
.mesh-traffic-status { font-size:11px; color:var(--mesh-primary); }
|
||||||
.mesh-traffic-status[data-state="error"] { color:#b23d2f; }
|
.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 { 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 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; }
|
.mesh-traffic-table td { padding:11px 14px; border-bottom:1px solid var(--mesh-border); text-align:left; }
|
||||||
|
|||||||
@@ -5,10 +5,12 @@
|
|||||||
{% block css %}
|
{% block css %}
|
||||||
{% include "data-page-styles.html" %}
|
{% include "data-page-styles.html" %}
|
||||||
<style>
|
<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 { 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 > 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-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; }
|
.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-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; }
|
.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">
|
<button onclick="showQrCode()" id="showQrBtn">
|
||||||
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
|
<span>🔳</span> <span data-translate-lang="show_qr_code">Show QR Code</span>
|
||||||
</button>
|
</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">
|
<button onclick="openReachReport()" id="reachReportBtn">
|
||||||
<span>📈</span> <span>Reliability</span>
|
<span>📈</span> <span>Reliability</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -57,24 +65,31 @@
|
|||||||
|
|
||||||
<!-- Node Info -->
|
<!-- Node Info -->
|
||||||
<div id="node-info" class="node-info">
|
<div id="node-info" class="node-info">
|
||||||
<div class="node-info-col">
|
<section class="node-info-col" aria-labelledby="node-info-heading-0">
|
||||||
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id">—</span></div>
|
<h2 id="node-info-heading-0">Identity</h2>
|
||||||
<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>
|
|
||||||
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name">—</span></div>
|
<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><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel">—</span></div>
|
||||||
</div>
|
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway">—</span></div>
|
||||||
<div class="node-info-col">
|
</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="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="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>Altitude (above sea level)</strong><strong>: </strong> <span id="info-altitude">—</span></div>
|
||||||
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model">—</span></div>
|
</section>
|
||||||
<div><strong data-translate-lang="mqtt_gateway">MQTT Gateway</strong><strong>: </strong> <span id="info-mqtt-gateway">—</span></div>
|
<section class="node-info-col" aria-labelledby="node-info-heading-3">
|
||||||
</div>
|
<h2 id="node-info-heading-3">Activity</h2>
|
||||||
<div class="node-info-col">
|
|
||||||
<div><strong data-translate-lang="first_update">First Update</strong><strong>: </strong> <span id="info-first-update">—</span></div>
|
<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="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>
|
<div>
|
||||||
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
|
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
|
||||||
<span id="info-stats"
|
<span id="info-stats"
|
||||||
@@ -82,7 +97,7 @@
|
|||||||
data-label-sent="Packets sent"
|
data-label-sent="Packets sent"
|
||||||
data-label-seen="Times seen">—</span>
|
data-label-seen="Times seen">—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Map. -->
|
<!-- Map. -->
|
||||||
@@ -150,7 +165,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Packet Filters -->
|
<!-- 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">
|
<select id="packet_since" aria-label="Packet time range">
|
||||||
<option value="">All time</option>
|
<option value="">All time</option>
|
||||||
<option value="3600">Last hour</option>
|
<option value="3600">Last hour</option>
|
||||||
@@ -420,9 +435,11 @@ async function loadNodeInfo(){
|
|||||||
document.getElementById("info-channel").textContent = node.channel ?? "—";
|
document.getElementById("info-channel").textContent = node.channel ?? "—";
|
||||||
|
|
||||||
document.getElementById("info-lat").textContent =
|
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 =
|
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 coverageBtn = document.getElementById("toggleCoverageBtn");
|
||||||
const coverageHelp = document.getElementById("coverageHelpLink");
|
const coverageHelp = document.getElementById("coverageHelpLink");
|
||||||
if (coverageBtn) {
|
if (coverageBtn) {
|
||||||
@@ -495,6 +512,10 @@ function initPacketPortFilter() {
|
|||||||
opt.textContent = `${PORT_LABEL_MAP[p]} (${p})`;
|
opt.textContent = `${PORT_LABEL_MAP[p]} (${p})`;
|
||||||
sel.appendChild(opt);
|
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 },
|
grid: { bottom: 60 },
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: "axis",
|
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: {
|
legend: {
|
||||||
type: "scroll",
|
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 blob = new Blob([csv], { type:"text/csv" });
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = URL.createObjectURL(blob);
|
link.href = URL.createObjectURL(blob);
|
||||||
@@ -1415,8 +1444,8 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
await drawNeighbors(fromNodeId, neighborIds);
|
await drawNeighbors(fromNodeId, neighborIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
await reloadPackets();
|
|
||||||
initPacketPortFilter();
|
initPacketPortFilter();
|
||||||
|
await reloadPackets();
|
||||||
await loadTelemetryCharts();
|
await loadTelemetryCharts();
|
||||||
await loadNeighborTimeSeries();
|
await loadNeighborTimeSeries();
|
||||||
await loadPacketHistogram();
|
await loadPacketHistogram();
|
||||||
@@ -1506,10 +1535,8 @@ async function loadNodeStats(nodeId) {
|
|||||||
const portName = PORT_LABEL_MAP[pkt.portnum] || `Port ${pkt.portnum}`;
|
const portName = PORT_LABEL_MAP[pkt.portnum] || `Port ${pkt.portnum}`;
|
||||||
const displayPacketId = pkt.packet_id || (pkt.portnum === 73 ? "Not a Packet" : "");
|
const displayPacketId = pkt.packet_id || (pkt.portnum === 73 ? "Not a Packet" : "");
|
||||||
|
|
||||||
// Escape quotes + line breaks for CSV safety
|
// Keep each packet on one line; csvFromRows() does the quoting.
|
||||||
const payload = (pkt.payload || "")
|
const payload = (pkt.payload || "").replace(/\r?\n/g, " ");
|
||||||
.replace(/"/g, '""')
|
|
||||||
.replace(/\r?\n/g, " ");
|
|
||||||
|
|
||||||
rows.push([
|
rows.push([
|
||||||
time,
|
time,
|
||||||
@@ -1518,11 +1545,11 @@ async function loadNodeStats(nodeId) {
|
|||||||
pkt.to_node_id,
|
pkt.to_node_id,
|
||||||
pkt.portnum,
|
pkt.portnum,
|
||||||
portName,
|
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 blob = new Blob([csv], { type: "text/csv" });
|
||||||
|
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
@@ -1543,8 +1570,16 @@ async function loadNodeQr() {
|
|||||||
try {
|
try {
|
||||||
const qrRes = await fetch(`/api/node/${fromNodeId}/qr`);
|
const qrRes = await fetch(`/api/node/${fromNodeId}/qr`);
|
||||||
const qrData = await qrRes.json();
|
const qrData = await qrRes.json();
|
||||||
if (qrRes.ok && qrData.meshtastic_url) {
|
if (qrRes.ok) {
|
||||||
currentMeshtasticUrl = qrData.meshtastic_url;
|
// 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";
|
actionsDiv.style.display = "flex";
|
||||||
} else {
|
} else {
|
||||||
actionsDiv.style.display = "none";
|
actionsDiv.style.display = "none";
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
.favorite-star { cursor:pointer; font-size:20px; color:#8b98aa; user-select:none; }
|
.favorite-star { cursor:pointer; font-size:20px; color:#8b98aa; user-select:none; }
|
||||||
.favorite-star.active { color:#ad7800; }
|
.favorite-star.active { color:#ad7800; }
|
||||||
.mesh-data-page .favorites-btn.active { background:#fff3d6; border-color:#eacb80; color:#8b6200; }
|
.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; }
|
.sort-icon { font-size:10px; }
|
||||||
#mobile-node-list { display:none; }
|
#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; }
|
.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>
|
<option value="" data-translate-lang="all_firmware">All Firmware</option>
|
||||||
</select>
|
</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
|
⭐ Show Favorites
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@
|
|||||||
<th data-translate-lang="role">Role <span class="sort-icon"></span></th>
|
<th data-translate-lang="role">Role <span class="sort-icon"></span></th>
|
||||||
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
|
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
|
||||||
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
|
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
|
||||||
|
<th>Altitude (m) <span class="sort-icon"></span></th>
|
||||||
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
|
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
|
||||||
<th data-translate-lang="mqtt_gateway">MQTT <span class="sort-icon"></span></th>
|
<th data-translate-lang="mqtt_gateway">MQTT <span class="sort-icon"></span></th>
|
||||||
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
|
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
|
||||||
@@ -105,7 +107,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody id="node-table-body">
|
<tbody id="node-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="11" style="text-align:center; color:var(--mesh-muted);" data-translate-lang="loading_nodes">
|
<td colspan="12" style="text-align:center; color:var(--mesh-muted);" data-translate-lang="loading_nodes">
|
||||||
Loading nodes...
|
Loading nodes...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -115,6 +117,11 @@
|
|||||||
|
|
||||||
<!-- Mobile Card View -->
|
<!-- Mobile Card View -->
|
||||||
<div id="mobile-node-list" style="display:none;"></div>
|
<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>
|
<script>
|
||||||
// =====================================================
|
// =====================================================
|
||||||
@@ -163,7 +170,7 @@ const minStatusMs = 300;
|
|||||||
const headers = document.querySelectorAll("thead th");
|
const headers = document.querySelectorAll("thead th");
|
||||||
const keyMap = [
|
const keyMap = [
|
||||||
"short_name","long_name","hw_model","firmware","role",
|
"short_name","long_name","hw_model","firmware","role",
|
||||||
"last_lat","last_long","channel","is_mqtt_gateway","last_seen_us"
|
"last_lat","last_long","last_altitude","channel","is_mqtt_gateway","last_seen_us"
|
||||||
];
|
];
|
||||||
|
|
||||||
function debounce(fn, delay = 250) {
|
function debounce(fn, delay = 250) {
|
||||||
@@ -226,7 +233,7 @@ function timeAgoFromMs(msTimestamp) {
|
|||||||
// =====================================================
|
// =====================================================
|
||||||
document.addEventListener("DOMContentLoaded", async function() {
|
document.addEventListener("DOMContentLoaded", async function() {
|
||||||
|
|
||||||
await loadTranslationsNodelist();
|
const translationsReady = loadTranslationsNodelist();
|
||||||
loadFavorites();
|
loadFavorites();
|
||||||
|
|
||||||
const tbody = document.getElementById("node-table-body");
|
const tbody = document.getElementById("node-table-body");
|
||||||
@@ -244,6 +251,12 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
const clearBtn = document.getElementById("clear-btn");
|
const clearBtn = document.getElementById("clear-btn");
|
||||||
const favoritesBtn = document.getElementById("favorites-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);
|
let lastIsMobile = (window.innerWidth <= 768);
|
||||||
|
|
||||||
async function loadNodesForActiveDays() {
|
async function loadNodesForActiveDays() {
|
||||||
@@ -254,6 +267,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
if (!res.ok) throw new Error("Failed to fetch nodes");
|
if (!res.ok) throw new Error("Failed to fetch nodes");
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
await translationsReady;
|
||||||
|
|
||||||
allNodes = data.nodes.map(n => {
|
allNodes = data.nodes.map(n => {
|
||||||
const firmware = n.firmware || n.firmware_version || "";
|
const firmware = n.firmware || n.firmware_version || "";
|
||||||
@@ -278,7 +292,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
populateFilters(allNodes);
|
populateFilters(allNodes);
|
||||||
applyFilters(); // ensures initial sort + render uses same path
|
await applyFilters(); // ensures initial sort + render uses same path
|
||||||
updateSortIcons();
|
updateSortIcons();
|
||||||
setStatus("");
|
setStatus("");
|
||||||
}
|
}
|
||||||
@@ -287,13 +301,22 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
await loadNodesForActiveDays();
|
await loadNodesForActiveDays();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
tbody.innerHTML = `<tr>
|
tbody.innerHTML = `<tr>
|
||||||
<td colspan="11" style="text-align:center; color:red;">
|
<td colspan="12" style="text-align:center; color:red;">
|
||||||
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
setStatus("");
|
setStatus("");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
previousPage.addEventListener("click", () => {
|
||||||
|
currentPage--;
|
||||||
|
applyFilters(false);
|
||||||
|
});
|
||||||
|
nextPage.addEventListener("click", () => {
|
||||||
|
currentPage++;
|
||||||
|
applyFilters(false);
|
||||||
|
});
|
||||||
|
|
||||||
roleFilter.addEventListener("change", applyFilters);
|
roleFilter.addEventListener("change", applyFilters);
|
||||||
channelFilter.addEventListener("change", applyFilters);
|
channelFilter.addEventListener("change", applyFilters);
|
||||||
hwFilter.addEventListener("change", applyFilters);
|
hwFilter.addEventListener("change", applyFilters);
|
||||||
@@ -304,7 +327,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to reload nodes:", err);
|
console.error("Failed to reload nodes:", err);
|
||||||
tbody.innerHTML = `<tr>
|
tbody.innerHTML = `<tr>
|
||||||
<td colspan="11" style="text-align:center; color:red;">
|
<td colspan="12" style="text-align:center; color:red;">
|
||||||
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
||||||
</td></tr>`;
|
</td></tr>`;
|
||||||
mobileList.innerHTML = "";
|
mobileList.innerHTML = "";
|
||||||
@@ -390,15 +413,19 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleFavoritesFilter() {
|
function toggleFavoritesFilter() {
|
||||||
showOnlyFavorites = !showOnlyFavorites;
|
if (showOnlyFavorites) {
|
||||||
favoritesBtn.textContent = showOnlyFavorites
|
clearFilters();
|
||||||
? "Show All"
|
return;
|
||||||
: "⭐ Show Favorites";
|
}
|
||||||
favoritesBtn.classList.toggle("active", showOnlyFavorites);
|
showOnlyFavorites = true;
|
||||||
|
favoritesBtn.textContent = "Show All";
|
||||||
|
favoritesBtn.classList.add("active");
|
||||||
|
favoritesBtn.setAttribute("aria-pressed", "true");
|
||||||
applyFilters();
|
applyFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyFilters() {
|
async function applyFilters(resetPage = true) {
|
||||||
|
if (resetPage !== false) currentPage = 1;
|
||||||
setStatus("Updating…");
|
setStatus("Updating…");
|
||||||
await nextFrame();
|
await nextFrame();
|
||||||
const searchTerm = searchBox.value.trim().toLowerCase();
|
const searchTerm = searchBox.value.trim().toLowerCase();
|
||||||
@@ -418,12 +445,21 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
|
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
|
||||||
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
||||||
|
|
||||||
|
filteredNodes = filtered;
|
||||||
renderTable(filtered);
|
renderTable(filtered);
|
||||||
updateSortIcons();
|
updateSortIcons();
|
||||||
setStatus("");
|
setStatus("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(nodes) {
|
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 isMobile = window.innerWidth <= 768;
|
||||||
const shouldRenderTable = !isMobile;
|
const shouldRenderTable = !isMobile;
|
||||||
|
|
||||||
@@ -439,7 +475,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
if (!nodes.length) {
|
if (!nodes.length) {
|
||||||
if (shouldRenderTable) {
|
if (shouldRenderTable) {
|
||||||
tbody.innerHTML = `<tr>
|
tbody.innerHTML = `<tr>
|
||||||
<td colspan="11" style="text-align:center; color:var(--mesh-muted);">
|
<td colspan="12" style="text-align:center; color:var(--mesh-muted);">
|
||||||
${nodelistTranslations.no_nodes_found || "No nodes found"}
|
${nodelistTranslations.no_nodes_found || "No nodes found"}
|
||||||
</td>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
@@ -468,6 +504,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
<td>${escapeHtml(node.role || "N/A")}</td>
|
<td>${escapeHtml(node.role || "N/A")}</td>
|
||||||
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
|
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
|
||||||
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
|
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
|
||||||
|
<td>${node.last_altitude != null ? escapeHtml(String(node.last_altitude)) : "N/A"}</td>
|
||||||
<td>${escapeHtml(node.channel || "N/A")}</td>
|
<td>${escapeHtml(node.channel || "N/A")}</td>
|
||||||
<td>${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")}</td>
|
<td>${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")}</td>
|
||||||
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
|
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
|
||||||
@@ -499,6 +536,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
|
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
|
||||||
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
|
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="node-card-field"><b>Altitude:</b> ${node.last_altitude != null ? `${escapeHtml(String(node.last_altitude))} m above sea level` : "N/A"}</div>
|
||||||
<div class="node-card-field"><b>Channel:</b> ${escapeHtml(node.channel || "N/A")}</div>
|
<div class="node-card-field"><b>Channel:</b> ${escapeHtml(node.channel || "N/A")}</div>
|
||||||
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
|
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
|
||||||
|
|
||||||
@@ -513,7 +551,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
// Toggle correct view
|
// Toggle correct view
|
||||||
mobileList.style.display = isMobile ? "block" : "none";
|
mobileList.style.display = isMobile ? "block" : "none";
|
||||||
|
|
||||||
countSpan.textContent = nodes.length;
|
countSpan.textContent = total;
|
||||||
|
|
||||||
if (shouldRenderTable) {
|
if (shouldRenderTable) {
|
||||||
tbody.appendChild(tableFrag);
|
tbody.appendChild(tableFrag);
|
||||||
@@ -533,31 +571,34 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
sortAsc = true;
|
sortAsc = true;
|
||||||
showOnlyFavorites = false;
|
showOnlyFavorites = false;
|
||||||
|
|
||||||
favoritesBtn.textContent = "⭐ Show Favorites";
|
favoritesBtn.textContent = nodelistTranslations.show_favorites || "⭐ Show Favorites";
|
||||||
favoritesBtn.classList.remove("active");
|
favoritesBtn.classList.remove("active");
|
||||||
|
favoritesBtn.setAttribute("aria-pressed", "false");
|
||||||
|
|
||||||
applyFilters();
|
applyFilters();
|
||||||
updateSortIcons();
|
updateSortIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportToCSV() {
|
function exportToCSV() {
|
||||||
const rows = [];
|
const rows = [Array.from(headers).map(h => h.innerText.replace(/▲|▼/g,''))];
|
||||||
const headerList = Array.from(headers).map(h =>
|
filteredNodes.forEach(node => {
|
||||||
`"${h.innerText.replace(/▲|▼/g,'')}"`
|
rows.push([
|
||||||
);
|
node.short_name || "N/A", node.long_name || "N/A", node.hw_model || "N/A",
|
||||||
rows.push(headerList.join(","));
|
node.firmware || "N/A", node.role || "N/A",
|
||||||
|
node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A",
|
||||||
const trs = tbody.querySelectorAll("tr");
|
node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A",
|
||||||
trs.forEach(tr => {
|
node.last_altitude ?? "N/A",
|
||||||
const cells = Array.from(tr.children).map(td =>
|
node.channel || "N/A",
|
||||||
`"${td.innerText.replace(/"/g,'""')}"`
|
node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No"),
|
||||||
);
|
timeAgoFromMs(node.last_seen_ms), isFavorite(node.node_id) ? "★" : "☆"
|
||||||
rows.push(cells.join(","));
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
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");
|
const a = document.createElement("a");
|
||||||
a.href = encodeURI(csv);
|
a.href = URL.createObjectURL(blob);
|
||||||
a.download = "nodelist.csv";
|
a.download = "nodelist.csv";
|
||||||
a.click();
|
a.click();
|
||||||
}
|
}
|
||||||
@@ -567,6 +608,12 @@ document.addEventListener("DOMContentLoaded", async function() {
|
|||||||
let A = a[key];
|
let A = a[key];
|
||||||
let B = b[key];
|
let B = b[key];
|
||||||
|
|
||||||
|
if (key === "last_altitude") {
|
||||||
|
if (A == null && B == null) return 0;
|
||||||
|
if (A == null) return 1;
|
||||||
|
if (B == null) return -1;
|
||||||
|
}
|
||||||
|
|
||||||
if (key === "last_seen_us") {
|
if (key === "last_seen_us") {
|
||||||
A = A || 0;
|
A = A || 0;
|
||||||
B = B || 0;
|
B = B || 0;
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
|||||||
${
|
${
|
||||||
telemetryExtras.length
|
telemetryExtras.length
|
||||||
? `<dt data-translate-lang="decoded_telemetry">${packetTranslations.decoded_telemetry || "Decoded Telemetry"}</dt>
|
? `<dt data-translate-lang="decoded_telemetry">${packetTranslations.decoded_telemetry || "Decoded Telemetry"}</dt>
|
||||||
<dd><pre>${telemetryExtras.join("\n")}</pre></dd>`
|
<dd><pre>${escapeHtml(telemetryExtras.join("\n"))}</pre></dd>`
|
||||||
: ""
|
: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
<section class="reach-page">
|
<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>
|
<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
|
<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
|
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
|
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
|
calculated only from Text, Position, Node Info, and Telemetry packets; other packet types
|
||||||
@@ -94,6 +94,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<p class="reach-muted" id="reach-channel" role="status"></p>
|
||||||
<div class="reach-summary">
|
<div class="reach-summary">
|
||||||
<div class="reach-metric">
|
<div class="reach-metric">
|
||||||
<div class="text-secondary">Latest Packets</div>
|
<div class="text-secondary">Latest Packets</div>
|
||||||
@@ -466,7 +467,19 @@ async function loadReachReport(nodeId = reachNodeId) {
|
|||||||
resetReliabilityMap();
|
resetReliabilityMap();
|
||||||
|
|
||||||
try {
|
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);
|
const packetUrl = new URL("/api/packets", window.location.origin);
|
||||||
|
packetUrl.searchParams.set("channel", mainChannel);
|
||||||
packetUrl.searchParams.set("from_node_id", reachNodeId);
|
packetUrl.searchParams.set("from_node_id", reachNodeId);
|
||||||
packetUrl.searchParams.set("limit", packetLimit);
|
packetUrl.searchParams.set("limit", packetLimit);
|
||||||
if (packetType) packetUrl.searchParams.set("portnum", packetType);
|
if (packetType) packetUrl.searchParams.set("portnum", packetType);
|
||||||
@@ -475,8 +488,7 @@ async function loadReachReport(nodeId = reachNodeId) {
|
|||||||
fetchJson(packetUrl),
|
fetchJson(packetUrl),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const packets = packetData.packets || [];
|
const packets = (packetData.packets || []).filter(packet => packet.channel === mainChannel);
|
||||||
const nodesById = new Map(reachNodes.map(node => [Number(node.node_id), node]));
|
|
||||||
|
|
||||||
packetCountEl.textContent = packets.length;
|
packetCountEl.textContent = packets.length;
|
||||||
|
|
||||||
|
|||||||
@@ -10,17 +10,29 @@
|
|||||||
.summary-card { padding:20px 24px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; min-width:0; }
|
.summary-card { padding:20px 24px; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:9px; min-width:0; }
|
||||||
.summary-card p { color:var(--mesh-muted); font-size:13px; margin:0 0 8px; }
|
.summary-card p { color:var(--mesh-muted); font-size:13px; margin:0 0 8px; }
|
||||||
.summary-count { font-size:30px; font-weight:650; color:var(--mesh-primary); font-variant-numeric:tabular-nums; }
|
.summary-count { font-size:30px; font-weight:650; color:var(--mesh-primary); font-variant-numeric:tabular-nums; }
|
||||||
.summary-card:nth-child(2) .summary-count { color:#198754; }
|
.summary-card:nth-child(2) .summary-count { color:var(--mesh-primary); }
|
||||||
.summary-card:nth-child(3) .summary-count { color:#236b4b; }
|
.summary-card:nth-child(3) .summary-count { color:var(--mesh-primary); }
|
||||||
.summary-card:nth-child(4) .summary-count { color:#497b55; }
|
.summary-card:nth-child(4) .summary-count { color:var(--mesh-primary); }
|
||||||
.stats-chart-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:20px; }
|
.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; }
|
.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; }
|
.total-count { font-size:12px; color:var(--mesh-muted); margin-right:auto; }
|
||||||
.chart { width:100%; min-width:0; height:340px; margin-top:8px; }
|
.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 { 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 > 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; }
|
#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:9px; line-height:1.2; padding:2px 4px; }
|
||||||
#modalChart { flex:1; min-height:0; width:100%; }
|
#modalChart { flex:1; min-height:0; width:100%; }
|
||||||
@media(max-width:1000px) { .stats-chart-grid { grid-template-columns:minmax(0,1fr); } }
|
@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; } }
|
@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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block head %}
|
{% 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="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||||
<script src="/static/portmaps.js"></script>
|
<script src="/static/portmaps.js"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -91,6 +105,27 @@
|
|||||||
<div id="chart_daily_snapshot_nodes_gateways" class="chart"></div>
|
<div id="chart_daily_snapshot_nodes_gateways" class="chart"></div>
|
||||||
</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 100 m above sea level are included. Click a bar to view the node.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Packet Types Pie Chart with Channel Selector -->
|
<!-- Packet Types Pie Chart with Channel Selector -->
|
||||||
<div class="card-section">
|
<div class="card-section">
|
||||||
<p class="section-header" data-translate-lang="packet_types_last_24h">
|
<p class="section-header" data-translate-lang="packet_types_last_24h">
|
||||||
@@ -181,6 +216,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="altitude-map-modal" role="dialog" aria-modal="true" aria-labelledby="altitude-map-title" hidden>
|
||||||
|
<div class="altitude-map-dialog">
|
||||||
|
<div class="altitude-map-heading"><h2 id="altitude-map-title">highest routers · top 50</h2><button id="altitude-map-close" type="button" aria-label="Close router map">×</button></div>
|
||||||
|
<p id="altitude-map-status" role="status"></p>
|
||||||
|
<div id="altitude-router-map" aria-label="Map of highest routers and their altitudes"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Modal for expanded charts -->
|
<!-- Modal for expanded charts -->
|
||||||
<div id="chartModal" role="dialog" aria-modal="true" aria-label="Expanded chart">
|
<div id="chartModal" role="dialog" aria-modal="true" aria-label="Expanded chart">
|
||||||
<div>
|
<div>
|
||||||
@@ -192,6 +235,214 @@
|
|||||||
<script>
|
<script>
|
||||||
const PORTNUM_LABELS = window.PORT_LABEL_MAP;
|
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 >= 100 && 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 100 m or more.";
|
||||||
|
} catch (error) {
|
||||||
|
altitudeRows = [];
|
||||||
|
altitudeExportRows = [];
|
||||||
|
chart.clear();
|
||||||
|
status.textContent = "Unable to load altitudes. Use Refresh report to retry.";
|
||||||
|
console.error(error);
|
||||||
|
} finally {
|
||||||
|
refresh.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.getElementById("altitude-refresh").addEventListener("click", () => loadAltitudeNodes(true));
|
||||||
|
loadAltitudeNodes();
|
||||||
|
|
||||||
|
// Map the same ranked sample used by the top-50 CSV export.
|
||||||
|
let altitudeMap;
|
||||||
|
let altitudeMarkers;
|
||||||
|
const altitudeMapModal = document.getElementById("altitude-map-modal");
|
||||||
|
const altitudeMapButton = document.getElementById("altitude-map-button");
|
||||||
|
const altitudeMapClose = document.getElementById("altitude-map-close");
|
||||||
|
function closeAltitudeMap() {
|
||||||
|
altitudeMapModal.hidden = true;
|
||||||
|
altitudeMapButton.focus();
|
||||||
|
}
|
||||||
|
altitudeMapClose.addEventListener("click", closeAltitudeMap);
|
||||||
|
altitudeMapModal.addEventListener("click", event => {
|
||||||
|
if (event.target === altitudeMapModal) closeAltitudeMap();
|
||||||
|
});
|
||||||
|
altitudeMapModal.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Escape") { event.preventDefault(); closeAltitudeMap(); }
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
const controls = [...altitudeMapModal.querySelectorAll('button, a[href], [tabindex="0"]')]
|
||||||
|
.filter(el => el.getClientRects().length);
|
||||||
|
const first = controls[0], last = controls[controls.length - 1];
|
||||||
|
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||||
|
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
altitudeMapButton.addEventListener("click", () => {
|
||||||
|
altitudeMapModal.hidden = false;
|
||||||
|
altitudeMapClose.focus();
|
||||||
|
if (!altitudeMap) {
|
||||||
|
altitudeMap = L.map("altitude-router-map").setView([0,0], 2);
|
||||||
|
L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution:'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, SRTM | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
|
||||||
|
maxZoom:17
|
||||||
|
}).addTo(altitudeMap);
|
||||||
|
altitudeMarkers = L.layerGroup().addTo(altitudeMap);
|
||||||
|
}
|
||||||
|
altitudeMarkers.clearLayers();
|
||||||
|
const located = altitudeExportRows.filter(node =>
|
||||||
|
Number.isFinite(node.last_lat) && Number.isFinite(node.last_long)
|
||||||
|
&& Math.abs(node.last_lat) <= 900000000 && Math.abs(node.last_long) <= 1800000000
|
||||||
|
&& (Math.abs(node.last_lat) > 10000000 || Math.abs(node.last_long) > 10000000));
|
||||||
|
const icon = L.divIcon({className:"altitude-router-icon", iconSize:[32,32], iconAnchor:[16,16],
|
||||||
|
html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="15" fill="#187346" stroke="white" stroke-width="2"/><g fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 21h12v4H10zM16 21v-7m-4 1a6 6 0 0 1 8 0m-11-3a10 10 0 0 1 14 0"/></g><circle cx="16" cy="12" r="1.5" fill="white"/></svg>'});
|
||||||
|
located.forEach(node => {
|
||||||
|
const marker = L.marker([node.last_lat / 1e7,node.last_long / 1e7], {icon}).addTo(altitudeMarkers);
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.textContent = `${node.short_name || node.long_name || node.id} · ${node.last_altitude.toLocaleString()} m`;
|
||||||
|
marker.bindTooltip(label, {permanent:true, direction:"top", offset:[0,-16], className:"altitude-router-label"});
|
||||||
|
const details = document.createElement("div");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = `/node/${encodeURIComponent(node.node_id)}`;
|
||||||
|
link.textContent = node.long_name || node.short_name || node.id;
|
||||||
|
const altitude = document.createElement("p");
|
||||||
|
altitude.textContent = `${node.last_altitude.toLocaleString()} m above sea level · ${node.role}`;
|
||||||
|
details.append(link,altitude);
|
||||||
|
marker.bindPopup(details);
|
||||||
|
});
|
||||||
|
const missing = altitudeExportRows.length - located.length;
|
||||||
|
document.getElementById("altitude-map-status").textContent =
|
||||||
|
`Showing ${located.length} of the top ${altitudeExportRows.length} routers · altitude in meters above sea level` +
|
||||||
|
(missing ? ` · ${missing} without a valid location` : '');
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
altitudeMap.invalidateSize();
|
||||||
|
if (located.length) altitudeMap.fitBounds(located.map(node => [node.last_lat / 1e7,node.last_long / 1e7]), {padding:[60,60], maxZoom:12});
|
||||||
|
else altitudeMap.setView([0,0],2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// --- Fetch & Processing ---
|
// --- Fetch & Processing ---
|
||||||
async function fetchStats(period_type,length,portnum=null,channel=null){
|
async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||||
try{
|
try{
|
||||||
@@ -214,14 +465,24 @@ async function fetchDailySnapshots(){
|
|||||||
}catch{return [];}
|
}catch{return [];}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchNodes(){
|
function fetchStatsNodes(refresh = false) {
|
||||||
try{
|
if (refresh) statsNodesPromise = null;
|
||||||
const res=await fetch("/api/nodes");
|
if (!statsNodesPromise) {
|
||||||
const json=await res.json();
|
statsNodesPromise = fetch("/api/nodes").then(async response => {
|
||||||
return json.nodes||[];
|
if (!response.ok) throw new Error("Unable to load nodes");
|
||||||
}catch{
|
const data = await response.json();
|
||||||
return [];
|
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(){
|
async function fetchChannels(){
|
||||||
@@ -247,12 +508,12 @@ function buildPacketTypeBreakdownFromSeries(allData, perPortSeries){
|
|||||||
}
|
}
|
||||||
|
|
||||||
function processCountField(nodes,field){
|
function processCountField(nodes,field){
|
||||||
const counts={};
|
const counts=new Map(); // keys are mesh-provided strings, e.g. a channel called "constructor"
|
||||||
nodes.forEach(n=>{
|
nodes.forEach(n=>{
|
||||||
const key=n[field]||"Unknown";
|
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){
|
function updateTotalCount(domId,data){
|
||||||
@@ -295,15 +556,20 @@ function renderChart(domId,data,type,color){
|
|||||||
const chart=initMeshChart(el);
|
const chart=initMeshChart(el);
|
||||||
const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():'');
|
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 counts=data.map(d=>d.count??d.packet_count??0);
|
||||||
|
const hourly = domId === 'chart_hourly_all' || domId === 'chart_portnum_1';
|
||||||
chart.setOption({
|
chart.setOption({
|
||||||
backgroundColor:'#ffffff',
|
backgroundColor:'#ffffff',
|
||||||
tooltip:{trigger:'axis'},
|
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:{
|
xAxis:{
|
||||||
type:'category',
|
type:'category',
|
||||||
data:periods,
|
data:periods,
|
||||||
axisLine:{lineStyle:{color:'#c3cfdf'}},
|
axisLine:{lineStyle:{color:'#c3cfdf'}},
|
||||||
axisLabel:{rotate:45,color:'#64748b'}
|
axisLabel:hourly
|
||||||
|
? {rotate:45, color:'#64748b', margin:16, hideOverlap:true}
|
||||||
|
: {rotate:45,color:'#64748b'}
|
||||||
},
|
},
|
||||||
yAxis:{
|
yAxis:{
|
||||||
type:'value',
|
type:'value',
|
||||||
@@ -330,7 +596,7 @@ function renderPieChart(elId,data,name){
|
|||||||
backgroundColor:"#ffffff",
|
backgroundColor:"#ffffff",
|
||||||
tooltip:{
|
tooltip:{
|
||||||
trigger:"item",
|
trigger:"item",
|
||||||
formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`
|
formatter: params=>`${escapeHtml(params.name)}: ${Math.round(params.percent)}% (${params.value})`
|
||||||
},
|
},
|
||||||
series:[{
|
series:[{
|
||||||
name:name,
|
name:name,
|
||||||
@@ -622,6 +888,9 @@ document.querySelectorAll(".expand-btn").forEach(btn=>{
|
|||||||
modal.style.display="flex";
|
modal.style.display="flex";
|
||||||
modalChart=initMeshChart(modalChartEl);
|
modalChart=initMeshChart(modalChartEl);
|
||||||
modalChart.setOption(sourceChart.getOption());
|
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){
|
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 blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
|
||||||
const link=document.createElement("a");
|
const link=document.createElement("a");
|
||||||
link.href=URL.createObjectURL(blob);
|
link.href=URL.createObjectURL(blob);
|
||||||
@@ -649,6 +918,19 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
|
|||||||
if(!chart)return;
|
if(!chart)return;
|
||||||
const option=chart.getOption();
|
const option=chart.getOption();
|
||||||
let rows=[];
|
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"){
|
if(option.series[0].type==="bar"||option.series[0].type==="line"){
|
||||||
const xData=option.xAxis[0].data;
|
const xData=option.xAxis[0].data;
|
||||||
const series = option.series || [];
|
const series = option.series || [];
|
||||||
|
|||||||
@@ -1,52 +1,56 @@
|
|||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme:light;
|
color-scheme:light;
|
||||||
--mesh-primary:#187346;
|
--mesh-primary:#245ea8;
|
||||||
--mesh-primary-dark:#0f5132;
|
--mesh-primary-dark:#19467f;
|
||||||
--mesh-primary-rgb:24,115,70;
|
--mesh-primary-rgb:36,94,168;
|
||||||
--mesh-brand:#187346;
|
--mesh-brand:#245ea8;
|
||||||
--mesh-brand-deep:#0f5132;
|
--mesh-brand-deep:#19467f;
|
||||||
--mesh-surface:#ffffff;
|
--mesh-surface:#ffffff;
|
||||||
--mesh-text:#243247;
|
--mesh-text:#243247;
|
||||||
--mesh-soft:#eaf5ee;
|
--mesh-soft:#eaf1fb;
|
||||||
--mesh-hover:#dceee2;
|
--mesh-hover:#dce8f8;
|
||||||
--mesh-border:#dce7df;
|
--mesh-border:#dce4ef;
|
||||||
--mesh-page:#f5f9f6;
|
--mesh-page:#f5f8fc;
|
||||||
--mesh-stripe:#f7faf8;
|
--mesh-stripe:#f7f9fc;
|
||||||
--mesh-muted:#64748b;
|
--mesh-muted:#64748b;
|
||||||
--mesh-chat-stripe:#eef6f0;
|
--mesh-chat-stripe:#eef3fb;
|
||||||
--mesh-pulse-soft:#edf8f0;
|
--mesh-pulse-soft:#edf4ff;
|
||||||
--mesh-pulse-peak:#ccebd7;
|
--mesh-pulse-peak:#c9defa;
|
||||||
--mesh-pulse-mid:#e2f3e8;
|
--mesh-pulse-mid:#deebfc;
|
||||||
}
|
}
|
||||||
[data-bs-theme="dark"] {
|
[data-bs-theme="dark"] {
|
||||||
color-scheme:dark;
|
color-scheme:dark;
|
||||||
--mesh-primary:#80cfa3;
|
--mesh-primary:#8bbcff;
|
||||||
--mesh-primary-dark:#a3e3be;
|
--mesh-primary-dark:#b6d5ff;
|
||||||
--mesh-primary-rgb:128,207,163;
|
--mesh-primary-rgb:139,188,255;
|
||||||
--mesh-brand:#14532d;
|
--mesh-brand:#193d69;
|
||||||
--mesh-brand-deep:#0c3a1f;
|
--mesh-brand-deep:#112b4b;
|
||||||
--mesh-surface:#18231e;
|
--mesh-surface:#182335;
|
||||||
--mesh-text:#e2ece5;
|
--mesh-text:#e2eaf6;
|
||||||
--mesh-soft:#203c2b;
|
--mesh-soft:#203653;
|
||||||
--mesh-hover:#294c36;
|
--mesh-hover:#29486c;
|
||||||
--mesh-border:#34483c;
|
--mesh-border:#34465f;
|
||||||
--mesh-page:#101914;
|
--mesh-page:#101925;
|
||||||
--mesh-stripe:#1c2a22;
|
--mesh-stripe:#1c2a3d;
|
||||||
--mesh-muted:#9faea6;
|
--mesh-muted:#a0aec2;
|
||||||
--mesh-chat-stripe:#203227;
|
--mesh-chat-stripe:#20314a;
|
||||||
--mesh-pulse-soft:#203c2b;
|
--mesh-pulse-soft:#203653;
|
||||||
--mesh-pulse-peak:#356b49;
|
--mesh-pulse-peak:#355d8e;
|
||||||
--mesh-pulse-mid:#294c36;
|
--mesh-pulse-mid:#29486c;
|
||||||
--bs-link-color:var(--mesh-primary);
|
--bs-link-color:var(--mesh-primary);
|
||||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||||
--bs-link-hover-color:var(--mesh-primary-dark);
|
--bs-link-hover-color:var(--mesh-primary-dark);
|
||||||
--bs-link-hover-color-rgb:163,227,190;
|
--bs-link-hover-color-rgb:182,213,255;
|
||||||
}
|
}
|
||||||
body { background:var(--mesh-page); color:var(--mesh-text); }
|
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-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-header .mesh-theme-toggle { margin-left:auto; }
|
||||||
.mesh-theme-toggle:hover { background:#ffffff20; }
|
.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; }
|
.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; } }
|
@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); }
|
[data-bs-theme="dark"] :is(.chat-status,.chat-details,.chat-channel,.chat-status[data-state="paused"],.chat-status[data-state="loading"],.good-x) { background:var(--mesh-soft); color:var(--mesh-primary); border-color:var(--mesh-border); }
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ async function loadTraceroute() {
|
|||||||
tooltip: {
|
tooltip: {
|
||||||
formatter: () => {
|
formatter: () => {
|
||||||
const longName = nodeLongNameById.get(n.name) || n.name;
|
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})`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ class Packet:
|
|||||||
text_payload = (
|
text_payload = (
|
||||||
"ACK"
|
"ACK"
|
||||||
if payload.error_reason == Routing.Error.NONE
|
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):
|
elif isinstance(payload, Message):
|
||||||
text_payload = text_format.MessageToString(payload)
|
text_payload = text_format.MessageToString(payload)
|
||||||
@@ -369,7 +369,10 @@ async def traceroute_page(request):
|
|||||||
# Keep !!
|
# Keep !!
|
||||||
@routes.get("/graph/traceroute/{packet_id}")
|
@routes.get("/graph/traceroute/{packet_id}")
|
||||||
async def graph_traceroute(request):
|
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))
|
traceroutes = list(await store.get_traceroute(packet_id))
|
||||||
|
|
||||||
packet = await store.get_packet(packet_id)
|
packet = await store.get_packet(packet_id)
|
||||||
|
|||||||
@@ -170,6 +170,57 @@ COVERAGE_LIMITS = {
|
|||||||
COVERAGE_MAX_STEPS = 4_000 # radius_km / step_km
|
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):
|
def init_api_module(packet_class, seq_regex, lang_dir):
|
||||||
"""Initialize API module with dependencies from main web module."""
|
"""Initialize API module with dependencies from main web module."""
|
||||||
global Packet, SEQ_REGEX, LANG_DIR
|
global Packet, SEQ_REGEX, LANG_DIR
|
||||||
@@ -181,7 +232,7 @@ def init_api_module(packet_class, seq_regex, lang_dir):
|
|||||||
@routes.get("/api/channels")
|
@routes.get("/api/channels")
|
||||||
async def api_channels(request: web.Request):
|
async def api_channels(request: web.Request):
|
||||||
period_type = request.query.get("period_type", "hour")
|
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:
|
try:
|
||||||
channels = await store.get_channels_in_period(period_type, length)
|
channels = await store.get_channels_in_period(period_type, length)
|
||||||
@@ -225,6 +276,7 @@ async def api_nodes(request):
|
|||||||
"role": n.role,
|
"role": n.role,
|
||||||
"last_lat": getattr(n, "last_lat", None),
|
"last_lat": getattr(n, "last_lat", None),
|
||||||
"last_long": getattr(n, "last_long", None),
|
"last_long": getattr(n, "last_long", None),
|
||||||
|
"last_altitude": n.last_altitude,
|
||||||
"channel": n.channel,
|
"channel": n.channel,
|
||||||
"is_mqtt_gateway": getattr(n, "is_mqtt_gateway", None),
|
"is_mqtt_gateway": getattr(n, "is_mqtt_gateway", None),
|
||||||
# "last_update": n.last_update.isoformat(),
|
# "last_update": n.last_update.isoformat(),
|
||||||
@@ -249,6 +301,7 @@ async def api_packets(request):
|
|||||||
since_str = request.query.get("since")
|
since_str = request.query.get("since")
|
||||||
portnum_str = request.query.get("portnum")
|
portnum_str = request.query.get("portnum")
|
||||||
contains = request.query.get("contains")
|
contains = request.query.get("contains")
|
||||||
|
channel = request.query.get("channel")
|
||||||
|
|
||||||
# NEW — explicit filters
|
# NEW — explicit filters
|
||||||
from_node_id_str = request.query.get("from_node_id")
|
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 a packet_id is provided, return only that packet ---
|
||||||
if packet_id_str:
|
if packet_id_str:
|
||||||
try:
|
try:
|
||||||
packet_id = int(packet_id_str)
|
packet_id = parse_db_int(packet_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return web.json_response({"error": "Invalid packet_id format"}, status=400)
|
return web.json_response({"error": "Invalid packet_id format"}, status=400)
|
||||||
|
|
||||||
@@ -291,17 +344,17 @@ async def api_packets(request):
|
|||||||
since = None
|
since = None
|
||||||
if since_str:
|
if since_str:
|
||||||
try:
|
try:
|
||||||
since = int(since_str)
|
since = parse_db_int(since_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Invalid 'since' value (expected microseconds): {since_str}")
|
logger.warning("Invalid 'since' value (expected microseconds): %r", since_str)
|
||||||
|
|
||||||
# --- Parse portnum ---
|
# --- Parse portnum ---
|
||||||
portnum = None
|
portnum = None
|
||||||
if portnum_str:
|
if portnum_str:
|
||||||
try:
|
try:
|
||||||
portnum = int(portnum_str)
|
portnum = parse_db_int(portnum_str, bits=32)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"Invalid portnum: {portnum_str}")
|
logger.warning("Invalid portnum: %r", portnum_str)
|
||||||
|
|
||||||
# --- Parse node filters ---
|
# --- Parse node filters ---
|
||||||
from_node_id = None
|
from_node_id = None
|
||||||
@@ -310,21 +363,21 @@ async def api_packets(request):
|
|||||||
|
|
||||||
if from_node_id_str:
|
if from_node_id_str:
|
||||||
try:
|
try:
|
||||||
from_node_id = int(from_node_id_str, 0)
|
from_node_id = parse_db_int(from_node_id_str, 0)
|
||||||
except ValueError:
|
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:
|
if to_node_id_str:
|
||||||
try:
|
try:
|
||||||
to_node_id = int(to_node_id_str, 0)
|
to_node_id = parse_db_int(to_node_id_str, 0)
|
||||||
except ValueError:
|
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:
|
if node_id_str:
|
||||||
try:
|
try:
|
||||||
node_id = int(node_id_str, 0)
|
node_id = parse_db_int(node_id_str, 0)
|
||||||
except ValueError:
|
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 ---
|
# --- Fetch packets using explicit filters ---
|
||||||
contains_for_query = contains
|
contains_for_query = contains
|
||||||
@@ -339,6 +392,7 @@ async def api_packets(request):
|
|||||||
after=since,
|
after=since,
|
||||||
contains=contains_for_query,
|
contains=contains_for_query,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
|
channel=channel,
|
||||||
)
|
)
|
||||||
|
|
||||||
ui_packets = [Packet.from_model(p) for p in packets]
|
ui_packets = [Packet.from_model(p) for p in packets]
|
||||||
@@ -417,16 +471,13 @@ async def api_stats(request):
|
|||||||
status=400,
|
status=400,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
length = _query_int(request, "length", 24, 1, MAX_PERIOD_LENGTH)
|
||||||
length = int(request.query.get("length", 24))
|
|
||||||
except ValueError:
|
|
||||||
return web.json_response({"error": "length must be an integer"}, status=400)
|
|
||||||
|
|
||||||
# NEW: optional combined node stats
|
# NEW: optional combined node stats
|
||||||
node_str = request.query.get("node")
|
node_str = request.query.get("node")
|
||||||
if node_str:
|
if node_str:
|
||||||
try:
|
try:
|
||||||
node_id = int(node_str)
|
node_id = parse_db_int(node_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return web.json_response({"error": "node must be an integer"}, status=400)
|
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) ----
|
# ---- Existing full stats mode (unchanged) ----
|
||||||
channel = request.query.get("channel")
|
channel = request.query.get("channel")
|
||||||
|
|
||||||
def parse_int_param(name):
|
def parse_int_param(name, bits=64):
|
||||||
value = request.query.get(name)
|
value = request.query.get(name)
|
||||||
if value is not None:
|
if value is not None:
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return parse_db_int(value, bits=bits)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise web.HTTPBadRequest(
|
raise web.HTTPBadRequest(
|
||||||
text=json.dumps({"error": f"{name} must be an integer"}),
|
text=json.dumps({"error": f"{name} must be an integer"}),
|
||||||
@@ -469,7 +520,7 @@ async def api_stats(request):
|
|||||||
) from None
|
) from None
|
||||||
return None
|
return None
|
||||||
|
|
||||||
portnum = parse_int_param("portnum")
|
portnum = parse_int_param("portnum", bits=32)
|
||||||
to_node = parse_int_param("to_node")
|
to_node = parse_int_param("to_node")
|
||||||
from_node = parse_int_param("from_node")
|
from_node = parse_int_param("from_node")
|
||||||
|
|
||||||
@@ -499,18 +550,12 @@ async def api_stats_count(request):
|
|||||||
packet_id = None
|
packet_id = None
|
||||||
if packet_id_str:
|
if packet_id_str:
|
||||||
try:
|
try:
|
||||||
packet_id = int(packet_id_str)
|
packet_id = parse_db_int(packet_id_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return web.json_response({"error": "packet_id must be integer"}, status=400)
|
return web.json_response({"error": "packet_id must be integer"}, status=400)
|
||||||
|
|
||||||
period_type = request.query.get("period_type")
|
period_type = request.query.get("period_type")
|
||||||
length_str = request.query.get("length")
|
length = _query_int(request, "length", None, 1, MAX_PERIOD_LENGTH)
|
||||||
length = None
|
|
||||||
if length_str:
|
|
||||||
try:
|
|
||||||
length = int(length_str)
|
|
||||||
except ValueError:
|
|
||||||
return web.json_response({"error": "length must be integer"}, status=400)
|
|
||||||
|
|
||||||
channel = request.query.get("channel")
|
channel = request.query.get("channel")
|
||||||
|
|
||||||
@@ -519,7 +564,7 @@ async def api_stats_count(request):
|
|||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return parse_db_int(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise web.HTTPBadRequest(
|
raise web.HTTPBadRequest(
|
||||||
text=json.dumps({"error": f"{name} must be integer"}),
|
text=json.dumps({"error": f"{name} must be integer"}),
|
||||||
@@ -670,6 +715,8 @@ async def api_edges(request):
|
|||||||
_, neighbor_info = decode_payload.decode(packet)
|
_, neighbor_info = decode_payload.decode(packet)
|
||||||
except Exception:
|
except Exception:
|
||||||
continue
|
continue
|
||||||
|
if neighbor_info is None: # payload did not parse as NeighborInfo
|
||||||
|
continue
|
||||||
|
|
||||||
for node in neighbor_info.neighbors:
|
for node in neighbor_info.neighbors:
|
||||||
edge = (node.node_id, packet.from_node_id)
|
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)
|
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}")
|
@routes.get("/api/packets_seen/{packet_id}")
|
||||||
async def api_packets_seen(request):
|
async def api_packets_seen(request):
|
||||||
try:
|
try:
|
||||||
# --- Validate packet_id ---
|
# --- Validate packet_id ---
|
||||||
try:
|
try:
|
||||||
packet_id = int(request.match_info["packet_id"])
|
packet_id = parse_db_int(request.match_info["packet_id"])
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return web.json_response(
|
return web.json_response(
|
||||||
{"error": "Invalid or missing packet_id"},
|
{"error": "Invalid or missing packet_id"},
|
||||||
@@ -931,7 +985,7 @@ async def api_packets_seen(request):
|
|||||||
"hop_limit": row.hop_limit,
|
"hop_limit": row.hop_limit,
|
||||||
"hop_start": row.hop_start,
|
"hop_start": row.hop_start,
|
||||||
"channel": row.channel,
|
"channel": row.channel,
|
||||||
"rx_snr": row.rx_snr,
|
"rx_snr": finite_or_none(row.rx_snr),
|
||||||
"rx_rssi": row.rx_rssi,
|
"rx_rssi": row.rx_rssi,
|
||||||
"topic": row.topic,
|
"topic": row.topic,
|
||||||
"import_time_us": row.import_time_us,
|
"import_time_us": row.import_time_us,
|
||||||
@@ -950,7 +1004,7 @@ async def api_packets_seen(request):
|
|||||||
|
|
||||||
@routes.get("/api/traceroute/{packet_id}")
|
@routes.get("/api/traceroute/{packet_id}")
|
||||||
async def api_traceroute(request):
|
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))
|
traceroutes = list(await store.get_traceroute(packet_id))
|
||||||
packet = await store.get_packet(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")
|
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")
|
channel = request.query.get("channel")
|
||||||
|
|
||||||
limit = min(int(request.query.get("limit", 20)), 100)
|
# SQLite reads a negative LIMIT as "no limit", so the lower bound matters too.
|
||||||
offset = int(request.query.get("offset", 0))
|
limit = _query_int(request, "limit", 20, 1, 100)
|
||||||
|
offset = _query_int(request, "offset", 0, 0, None)
|
||||||
|
|
||||||
multiplier = 3600 if period_type == "hour" else 86400
|
multiplier = 3600 if period_type == "hour" else 86400
|
||||||
window_us = length * multiplier * 1_000_000
|
window_us = length * multiplier * 1_000_000
|
||||||
@@ -1163,7 +1218,7 @@ async def api_node_qr(request):
|
|||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
node_id_str = request.match_info["node_id"]
|
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):
|
except (KeyError, ValueError):
|
||||||
return web.json_response({"error": "Invalid node_id"}, status=400)
|
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)
|
return web.json_response({"error": "Node not found"}, status=404)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
from google.protobuf.message import DecodeError
|
||||||
|
|
||||||
from meshtastic.protobuf.admin_pb2 import SharedContact
|
from meshtastic.protobuf.admin_pb2 import SharedContact
|
||||||
from meshtastic.protobuf.config_pb2 import Config
|
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 = User()
|
||||||
user.id = f"!{node_id:08x}"
|
user.id = f"!{node_id:08x}"
|
||||||
@@ -1199,18 +1256,52 @@ async def api_node_qr(request):
|
|||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
contact = SharedContact()
|
# Meshview does not store public keys, so read them from the node's NODEINFO packets.
|
||||||
contact.node_num = node_id
|
latest_nodeinfo = None
|
||||||
contact.user.CopyFrom(user)
|
public_keys = set()
|
||||||
contact.manually_verified = False
|
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
|
import base64
|
||||||
|
|
||||||
contact_b64 = base64.b64encode(contact_bytes).decode("ascii")
|
# A contact without a public key is refused by current Meshtastic apps and, on older
|
||||||
contact_b64url = contact_b64.replace("+", "-").replace("/", "_").rstrip("=")
|
# 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(
|
return web.json_response(
|
||||||
{
|
{
|
||||||
@@ -1218,6 +1309,10 @@ async def api_node_qr(request):
|
|||||||
"long_name": node.long_name,
|
"long_name": node.long_name,
|
||||||
"short_name": node.short_name,
|
"short_name": node.short_name,
|
||||||
"role": node.role,
|
"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,
|
"meshtastic_url": meshtastic_url,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -1232,7 +1327,7 @@ async def api_node_qr(request):
|
|||||||
@routes.get("/api/coverage/{node_id}")
|
@routes.get("/api/coverage/{node_id}")
|
||||||
async def api_coverage(request):
|
async def api_coverage(request):
|
||||||
try:
|
try:
|
||||||
node_id = int(request.match_info["node_id"], 0)
|
node_id = parse_db_int(request.match_info["node_id"], 0)
|
||||||
except (KeyError, ValueError):
|
except (KeyError, ValueError):
|
||||||
return web.json_response({"error": "Invalid node_id"}, status=400)
|
return web.json_response({"error": "Invalid node_id"}, status=400)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "meshview"
|
name = "meshview"
|
||||||
version = "3.0.0"
|
version = "4.0.0"
|
||||||
description = "Real-time monitoring and diagnostic tool for the Meshtastic mesh network"
|
description = "Real-time monitoring and diagnostic tool for the Meshtastic mesh network"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ async def load_database_from_mqtt(
|
|||||||
await mqtt_store.process_envelope(topic, env)
|
await mqtt_store.process_envelope(topic, env)
|
||||||
except Exception:
|
except Exception:
|
||||||
# One bad or unexpected message must not stop ingestion for everyone.
|
# One bad or unexpected message must not stop ingestion for everyone.
|
||||||
logger.exception("Failed to store a message from topic %s; skipping it", topic)
|
logger.exception("Failed to store a message from topic %r; skipping it", topic)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""Persist reported altitudes without losing them when a report omits altitude."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from meshtastic.protobuf.mesh_pb2 import Position
|
||||||
|
from meshtastic.protobuf.mqtt_pb2 import MapReport, ServiceEnvelope
|
||||||
|
from meshtastic.protobuf.portnums_pb2 import PortNum
|
||||||
|
from meshview import mqtt_database, mqtt_store
|
||||||
|
from meshview.models import Base, Node
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize("report_type", [Position, MapReport])
|
||||||
|
async def test_altitude_updates(monkeypatch, report_type):
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(mqtt_database, "async_session", sessions, raising=False)
|
||||||
|
monkeypatch.setattr(mqtt_store, "MQTT_GATEWAY_CACHE", set())
|
||||||
|
port = PortNum.POSITION_APP if report_type is Position else PortNum.MAP_REPORT_APP
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(Base.metadata.create_all)
|
||||||
|
cases = [
|
||||||
|
(None, 200000000, None),
|
||||||
|
(125, 200000000, 125),
|
||||||
|
(None, 200000000, 125),
|
||||||
|
(-30, 200000000, -30),
|
||||||
|
(900, 0, -30), # Invalid location must not update altitude.
|
||||||
|
(0, 200000000, 0 if report_type is Position else -30),
|
||||||
|
]
|
||||||
|
for packet_id, (altitude, coordinate, expected) in enumerate(cases, start=1):
|
||||||
|
report = report_type(latitude_i=coordinate, longitude_i=coordinate)
|
||||||
|
if altitude is not None:
|
||||||
|
report.altitude = altitude
|
||||||
|
env = ServiceEnvelope(gateway_id="!00000002", channel_id="test")
|
||||||
|
env.packet.id = packet_id
|
||||||
|
setattr(env.packet, "from", 123)
|
||||||
|
env.packet.decoded.portnum = port
|
||||||
|
env.packet.decoded.payload = report.SerializeToString()
|
||||||
|
await mqtt_store.process_envelope("test", env)
|
||||||
|
async with sessions() as session:
|
||||||
|
node = (await session.execute(select(Node).where(Node.node_id == 123))).scalar_one()
|
||||||
|
assert node.last_altitude == expected
|
||||||
|
assert node.to_dict()["last_altitude"] == expected
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Reliability samples must select the newest packets within the main channel."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from meshview import database, models, store
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_channel_filter_applies_before_limit(monkeypatch):
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
sessions = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
monkeypatch.setattr(database, "async_session", sessions)
|
||||||
|
try:
|
||||||
|
async with engine.begin() as conn:
|
||||||
|
await conn.run_sync(models.Base.metadata.create_all)
|
||||||
|
async with sessions() as session:
|
||||||
|
session.add_all(
|
||||||
|
[
|
||||||
|
models.Packet(
|
||||||
|
id=1, from_node_id=123, portnum=1, channel="Main", import_time_us=100
|
||||||
|
),
|
||||||
|
models.Packet(
|
||||||
|
id=2, from_node_id=123, portnum=1, channel="Main", import_time_us=200
|
||||||
|
),
|
||||||
|
models.Packet(
|
||||||
|
id=3, from_node_id=123, portnum=1, channel="Other", import_time_us=300
|
||||||
|
),
|
||||||
|
models.Packet(
|
||||||
|
id=4, from_node_id=456, portnum=1, channel="Main", import_time_us=400
|
||||||
|
),
|
||||||
|
models.Packet(
|
||||||
|
id=5, from_node_id=123, portnum=3, channel="Main", import_time_us=500
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
packets = await store.get_packets(from_node_id=123, portnum=1, channel="Main", limit=2)
|
||||||
|
assert [p.id for p in packets] == [2, 1]
|
||||||
|
packets = await store.get_packets(from_node_id=123, portnum=1, limit=2)
|
||||||
|
assert [p.id for p in packets] == [3, 2]
|
||||||
|
assert await store.get_packets(from_node_id=123, channel="Unknown", limit=2) == []
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||