Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
194658d303 | ||
|
|
e4483ec02a | ||
|
|
e1e34c120e | ||
|
|
63abac5448 | ||
|
|
65fb307d07 | ||
|
|
f5d5dd6049 | ||
|
|
8446ef8956 |
No files matched your search
@@ -1,96 +1,75 @@
|
||||
# Running MeshView with Docker
|
||||
|
||||
MeshView container images are built automatically and published to GitHub Container Registry.
|
||||
The examples below use a Bash-compatible shell and Docker Compose v2 (`docker compose`).
|
||||
|
||||
## Quick Start
|
||||
|
||||
Pull and run the latest image:
|
||||
Prepare the configuration and persistent directories before starting the container:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
-p 8081:8081 \
|
||||
-v ./config:/etc/meshview \
|
||||
-v ./data:/var/lib/meshview \
|
||||
-v ./logs:/var/log/meshview \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
Access the web interface at: http://localhost:8081
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
The container uses three volumes for persistent data:
|
||||
|
||||
| Volume | Purpose | Required |
|
||||
|--------|---------|----------|
|
||||
| `/etc/meshview` | Configuration files | Yes |
|
||||
| `/var/lib/meshview` | Database storage | Recommended |
|
||||
| `/var/log/meshview` | Log files | Optional |
|
||||
|
||||
### Configuration Volume
|
||||
|
||||
Mount a directory containing your `config.ini` file:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/config:/etc/meshview
|
||||
```
|
||||
|
||||
If no config is provided, the container will use the default `sample.config.ini`.
|
||||
|
||||
### Database Volume
|
||||
|
||||
Mount a directory to persist the SQLite database:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/data:/var/lib/meshview
|
||||
```
|
||||
|
||||
**Important:** Without this mount, your database will be lost when the container stops.
|
||||
|
||||
### Logs Volume
|
||||
|
||||
Mount a directory to access logs from the host:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/logs:/var/log/meshview
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Create a directory structure and run:
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p meshview/{config,data,logs,backups}
|
||||
|
||||
# Copy sample config (first time only)
|
||||
docker run --rm ghcr.io/pablorevilla-meshtastic/meshview:latest \
|
||||
cat /etc/meshview/config.ini > meshview/config/config.ini
|
||||
# First-time setup only: extract the image's bundled configuration.
|
||||
# --entrypoint bypasses the normal MeshView startup command.
|
||||
docker run --rm --entrypoint cat \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest \
|
||||
/etc/meshview/config.ini > meshview/config/config.ini
|
||||
|
||||
# Edit config.ini with your MQTT settings
|
||||
# Edit MQTT settings, site settings, and backup options before starting.
|
||||
nano meshview/config/config.ini
|
||||
|
||||
# Run the container
|
||||
# On Linux, give the container user (UID 10001) ownership of writable data.
|
||||
sudo chown -R 10001 meshview/data meshview/logs meshview/backups
|
||||
sudo chmod -R u+rwX meshview/data meshview/logs meshview/backups
|
||||
# The configuration stays owned by you and must be readable by the container.
|
||||
chmod 755 meshview/config
|
||||
chmod 644 meshview/config/config.ini
|
||||
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
--restart unless-stopped \
|
||||
-p 8081:8081 \
|
||||
-v $(pwd)/meshview/config:/etc/meshview \
|
||||
-v $(pwd)/meshview/data:/var/lib/meshview \
|
||||
-v $(pwd)/meshview/logs:/var/log/meshview \
|
||||
-v "$(pwd)/meshview/config:/etc/meshview:ro" \
|
||||
-v "$(pwd)/meshview/data:/var/lib/meshview" \
|
||||
-v "$(pwd)/meshview/logs:/var/log/meshview" \
|
||||
-v "$(pwd)/meshview/backups:/var/lib/meshview/backups" \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
Run the config extraction only for a new installation: the redirection overwrites any
|
||||
existing `config.ini`. On Docker Desktop, host file permissions may be managed differently;
|
||||
the requirement is that UID 10001 can read the configuration and write data, logs, and backups.
|
||||
|
||||
Access the web interface at http://localhost:8081. Check startup with `docker logs -f meshview`.
|
||||
Database migrations run automatically when MeshView starts.
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
| Container path | Purpose | Host directory in the examples |
|
||||
|----------------|---------|--------------------------------|
|
||||
| `/etc/meshview` | Read-only configuration containing `config.ini` | `meshview/config` |
|
||||
| `/var/lib/meshview` | SQLite database storage | `meshview/data` |
|
||||
| `/var/log/meshview` | File logs, when enabled | `meshview/logs` |
|
||||
| `/var/lib/meshview/backups` | Database backups, when enabled | `meshview/backups` |
|
||||
|
||||
An empty host configuration directory hides the image's bundled `config.ini` and prevents
|
||||
startup. There is no fallback when a mounted configuration directory lacks that file.
|
||||
If you omit the configuration mount entirely, the image's bundled configuration is used.
|
||||
|
||||
Stopping or restarting a container does not delete the database. Explicit host mounts make
|
||||
it straightforward to preserve and reuse data when removing or recreating containers.
|
||||
Without explicit mounts, the image declares anonymous volumes; do not rely on those being
|
||||
automatically reused by a replacement container, and do not delete them if you need their data.
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
Use the preparation steps from Quick Start, but skip its `docker run -d` command.
|
||||
Create `compose.yaml` **inside the `meshview` directory**, alongside `config`, `data`,
|
||||
`logs`, and `backups`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
meshview:
|
||||
image: ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
@@ -99,45 +78,57 @@ services:
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/etc/meshview
|
||||
- ./config:/etc/meshview:ro
|
||||
- ./data:/var/lib/meshview
|
||||
- ./logs:/var/log/meshview
|
||||
- ./backups:/var/lib/meshview/backups # For database backups
|
||||
- ./backups:/var/lib/meshview/backups
|
||||
environment:
|
||||
- TZ=America/Los_Angeles # Set your timezone
|
||||
TZ: America/Los_Angeles
|
||||
```
|
||||
|
||||
Run with:
|
||||
Start from that directory:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
cd meshview
|
||||
docker compose up -d
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
Choose either Compose or `docker run` to manage the installation. If switching from the
|
||||
Quick Start container to Compose, stop and remove the old container first, keeping its
|
||||
host directories: `docker stop meshview` followed by `docker rm meshview`.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Minimum Configuration
|
||||
|
||||
Edit your `config.ini` to configure MQTT connection:
|
||||
Edit the extracted `config.ini` rather than replacing it with a partial configuration.
|
||||
Keep the container server listening on all interfaces:
|
||||
|
||||
```ini
|
||||
[mqtt]
|
||||
server = mqtt.meshtastic.org
|
||||
topics = ["msh/US/#"]
|
||||
port = 1883
|
||||
username =
|
||||
password =
|
||||
[server]
|
||||
bind = 0.0.0.0
|
||||
port = 8081
|
||||
```
|
||||
|
||||
Set your MQTT broker, topics, and credentials in the existing `[mqtt]` section. The broker
|
||||
must be reachable from the container; `localhost` refers to the container itself.
|
||||
|
||||
Keep the database path inside the mounted data directory:
|
||||
|
||||
```ini
|
||||
[database]
|
||||
# SQLAlchemy async connection string.
|
||||
# Examples:
|
||||
# sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:////var/lib/meshview/packets.db
|
||||
```
|
||||
|
||||
The four slashes after `sqlite+aiosqlite:` specify an absolute Linux path. PostgreSQL
|
||||
is also supported with a connection string such as
|
||||
`postgresql+asyncpg://user:pass@host:5432/meshview`.
|
||||
|
||||
After configuration changes, restart the application with `docker restart meshview`, or
|
||||
`docker compose restart meshview` for a Compose installation.
|
||||
|
||||
### Database Backups
|
||||
|
||||
To enable automatic daily backups inside the container:
|
||||
Enable backups in the existing `[cleanup]` section:
|
||||
|
||||
```ini
|
||||
[cleanup]
|
||||
@@ -147,99 +138,123 @@ backup_hour = 2
|
||||
backup_minute = 00
|
||||
```
|
||||
|
||||
Then mount the backups directory:
|
||||
|
||||
```bash
|
||||
-v $(pwd)/meshview/backups:/var/lib/meshview/backups
|
||||
```
|
||||
Both startup examples mount this directory to `meshview/backups` on the host. Backups
|
||||
remain disabled until you enable them in the configuration.
|
||||
|
||||
## Available Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest build from the main branch |
|
||||
| `dev-v3` | Development branch |
|
||||
| `v1.2.3` | Specific version tags |
|
||||
| `latest` | Latest build from the repository's default branch |
|
||||
| Branch name | Builds published for that branch |
|
||||
| `3.0.6` | Example version tag; use a version published in the registry |
|
||||
|
||||
## Updating
|
||||
|
||||
Pull the latest image and restart:
|
||||
For a Compose installation, run from the directory containing `compose.yaml`:
|
||||
|
||||
```bash
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
For a `docker run` installation, run from the same parent directory used in Quick Start:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
docker restart meshview
|
||||
docker stop meshview
|
||||
docker rm meshview
|
||||
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
--restart unless-stopped \
|
||||
-p 8081:8081 \
|
||||
-v "$(pwd)/meshview/config:/etc/meshview:ro" \
|
||||
-v "$(pwd)/meshview/data:/var/lib/meshview" \
|
||||
-v "$(pwd)/meshview/logs:/var/log/meshview" \
|
||||
-v "$(pwd)/meshview/backups:/var/lib/meshview/backups" \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
Or with docker-compose:
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
Preserve any custom ports, mounts, or environment settings when recreating the container.
|
||||
Do not repeat the first-time configuration extraction during updates. Pulling an image
|
||||
and merely restarting an existing container does not switch it to the new image.
|
||||
|
||||
## Logs
|
||||
|
||||
View container logs:
|
||||
|
||||
```bash
|
||||
docker logs meshview
|
||||
|
||||
# Follow logs
|
||||
docker logs -f meshview
|
||||
|
||||
# Last 100 lines
|
||||
docker logs --tail 100 meshview
|
||||
```
|
||||
|
||||
For Compose installations, `docker compose logs -f meshview` also works.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker logs meshview
|
||||
```
|
||||
Check `docker logs meshview`. Verify that the mounted `config` directory contains a
|
||||
readable `config.ini`, and that the database and log directories are writable by UID 10001.
|
||||
|
||||
### Docker Desktop reports mounts denied
|
||||
|
||||
Make sure the installation directory is shared with Docker Desktop under Settings →
|
||||
Resources → File Sharing. Bind-mount source paths must exist on the Docker daemon's
|
||||
host; a remote daemon cannot mount directories that exist only on your client.
|
||||
|
||||
### Database permission issues
|
||||
|
||||
Ensure the data directory is writable:
|
||||
From the parent directory containing `meshview`, repair writable directory ownership:
|
||||
|
||||
```bash
|
||||
chmod -R 755 meshview/data
|
||||
sudo chown -R 10001 meshview/data meshview/logs meshview/backups
|
||||
sudo chmod -R u+rwX meshview/data meshview/logs meshview/backups
|
||||
```
|
||||
|
||||
Setting permissions to `755` alone does not let UID 10001 write to directories owned
|
||||
by another user. SQLite also needs to create journal/WAL files beside the database.
|
||||
|
||||
### Can't connect to MQTT
|
||||
|
||||
1. Check your MQTT configuration in `config.ini`
|
||||
2. Verify network connectivity from the container:
|
||||
```bash
|
||||
docker exec meshview ping mqtt.meshtastic.org
|
||||
```
|
||||
Check the broker hostname, port, credentials, and topics in `config.ini`.
|
||||
Test DNS resolution and a TCP connection using Python, which is included in the image:
|
||||
|
||||
```bash
|
||||
# Replace the hostname and port with your configured broker.
|
||||
docker exec meshview /opt/venv/bin/python -c \
|
||||
'import socket; s = socket.create_connection(("mqtt.meshtastic.org", 1883), timeout=10); print("TCP connection successful"); s.close()'
|
||||
```
|
||||
|
||||
This checks network connectivity, not MQTT authentication or topic permissions.
|
||||
The image does not include `ping`.
|
||||
|
||||
### Port already in use
|
||||
|
||||
Change the host port (left side):
|
||||
Change the host port (left side) to `8082`, keeping the container port at `8081`:
|
||||
|
||||
```bash
|
||||
-p 8082:8081
|
||||
```
|
||||
|
||||
Then access at: http://localhost:8082
|
||||
For Compose, use `"8082:8081"` under `ports`. Access http://localhost:8082.
|
||||
|
||||
## Building Your Own Image
|
||||
|
||||
If you want to build from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pablorevilla-meshtastic/meshview.git
|
||||
cd meshview
|
||||
docker build -f Containerfile -t meshview:local .
|
||||
```
|
||||
|
||||
Use `meshview:local` instead of the registry image in the setup and run commands.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- The container runs as a non-root user (`app`, UID 10001)
|
||||
- No privileged access required
|
||||
- Only port 8081 is exposed
|
||||
- All data stored in mounted volumes
|
||||
- The container runs as a non-root user (`app`, UID 10001).
|
||||
- No privileged access is required.
|
||||
- The examples publish port 8081 on the host; configuration is mounted read-only.
|
||||
- Database, file logs, and backups use the mounted directories shown above.
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -17,47 +17,47 @@ code { background:var(--mesh-soft); padding:3px 6px; border-radius:4px; overflow
|
||||
<style>
|
||||
:root {
|
||||
color-scheme:light;
|
||||
--mesh-primary:#187346;
|
||||
--mesh-primary-dark:#0f5132;
|
||||
--mesh-primary-rgb:24,115,70;
|
||||
--mesh-brand:#187346;
|
||||
--mesh-brand-deep:#0f5132;
|
||||
--mesh-primary:#245ea8;
|
||||
--mesh-primary-dark:#19467f;
|
||||
--mesh-primary-rgb:36,94,168;
|
||||
--mesh-brand:#245ea8;
|
||||
--mesh-brand-deep:#19467f;
|
||||
--mesh-surface:#ffffff;
|
||||
--mesh-text:#243247;
|
||||
--mesh-soft:#eaf5ee;
|
||||
--mesh-hover:#dceee2;
|
||||
--mesh-border:#dce7df;
|
||||
--mesh-page:#f5f9f6;
|
||||
--mesh-stripe:#f7faf8;
|
||||
--mesh-soft:#eaf1fb;
|
||||
--mesh-hover:#dce8f8;
|
||||
--mesh-border:#dce4ef;
|
||||
--mesh-page:#f5f8fc;
|
||||
--mesh-stripe:#f7f9fc;
|
||||
--mesh-muted:#64748b;
|
||||
--mesh-chat-stripe:#eef6f0;
|
||||
--mesh-pulse-soft:#edf8f0;
|
||||
--mesh-pulse-peak:#ccebd7;
|
||||
--mesh-pulse-mid:#e2f3e8;
|
||||
--mesh-chat-stripe:#eef3fb;
|
||||
--mesh-pulse-soft:#edf4ff;
|
||||
--mesh-pulse-peak:#c9defa;
|
||||
--mesh-pulse-mid:#deebfc;
|
||||
}
|
||||
[data-bs-theme="dark"] {
|
||||
color-scheme:dark;
|
||||
--mesh-primary:#80cfa3;
|
||||
--mesh-primary-dark:#a3e3be;
|
||||
--mesh-primary-rgb:128,207,163;
|
||||
--mesh-brand:#14532d;
|
||||
--mesh-brand-deep:#0c3a1f;
|
||||
--mesh-surface:#18231e;
|
||||
--mesh-text:#e2ece5;
|
||||
--mesh-soft:#203c2b;
|
||||
--mesh-hover:#294c36;
|
||||
--mesh-border:#34483c;
|
||||
--mesh-page:#101914;
|
||||
--mesh-stripe:#1c2a22;
|
||||
--mesh-muted:#9faea6;
|
||||
--mesh-chat-stripe:#203227;
|
||||
--mesh-pulse-soft:#203c2b;
|
||||
--mesh-pulse-peak:#356b49;
|
||||
--mesh-pulse-mid:#294c36;
|
||||
--mesh-primary:#8bbcff;
|
||||
--mesh-primary-dark:#b6d5ff;
|
||||
--mesh-primary-rgb:139,188,255;
|
||||
--mesh-brand:#193d69;
|
||||
--mesh-brand-deep:#112b4b;
|
||||
--mesh-surface:#182335;
|
||||
--mesh-text:#e2eaf6;
|
||||
--mesh-soft:#203653;
|
||||
--mesh-hover:#29486c;
|
||||
--mesh-border:#34465f;
|
||||
--mesh-page:#101925;
|
||||
--mesh-stripe:#1c2a3d;
|
||||
--mesh-muted:#a0aec2;
|
||||
--mesh-chat-stripe:#20314a;
|
||||
--mesh-pulse-soft:#203653;
|
||||
--mesh-pulse-peak:#355d8e;
|
||||
--mesh-pulse-mid:#29486c;
|
||||
--bs-link-color:var(--mesh-primary);
|
||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||
--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); }
|
||||
.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; }
|
||||
@@ -92,7 +92,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
<!-- mesh-theme:end -->
|
||||
</head>
|
||||
<body>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -41,47 +41,47 @@ footer { border-top:1px solid var(--border); padding:20px 24px; text-align:cente
|
||||
<style>
|
||||
:root {
|
||||
color-scheme:light;
|
||||
--mesh-primary:#187346;
|
||||
--mesh-primary-dark:#0f5132;
|
||||
--mesh-primary-rgb:24,115,70;
|
||||
--mesh-brand:#187346;
|
||||
--mesh-brand-deep:#0f5132;
|
||||
--mesh-primary:#245ea8;
|
||||
--mesh-primary-dark:#19467f;
|
||||
--mesh-primary-rgb:36,94,168;
|
||||
--mesh-brand:#245ea8;
|
||||
--mesh-brand-deep:#19467f;
|
||||
--mesh-surface:#ffffff;
|
||||
--mesh-text:#243247;
|
||||
--mesh-soft:#eaf5ee;
|
||||
--mesh-hover:#dceee2;
|
||||
--mesh-border:#dce7df;
|
||||
--mesh-page:#f5f9f6;
|
||||
--mesh-stripe:#f7faf8;
|
||||
--mesh-soft:#eaf1fb;
|
||||
--mesh-hover:#dce8f8;
|
||||
--mesh-border:#dce4ef;
|
||||
--mesh-page:#f5f8fc;
|
||||
--mesh-stripe:#f7f9fc;
|
||||
--mesh-muted:#64748b;
|
||||
--mesh-chat-stripe:#eef6f0;
|
||||
--mesh-pulse-soft:#edf8f0;
|
||||
--mesh-pulse-peak:#ccebd7;
|
||||
--mesh-pulse-mid:#e2f3e8;
|
||||
--mesh-chat-stripe:#eef3fb;
|
||||
--mesh-pulse-soft:#edf4ff;
|
||||
--mesh-pulse-peak:#c9defa;
|
||||
--mesh-pulse-mid:#deebfc;
|
||||
}
|
||||
[data-bs-theme="dark"] {
|
||||
color-scheme:dark;
|
||||
--mesh-primary:#80cfa3;
|
||||
--mesh-primary-dark:#a3e3be;
|
||||
--mesh-primary-rgb:128,207,163;
|
||||
--mesh-brand:#14532d;
|
||||
--mesh-brand-deep:#0c3a1f;
|
||||
--mesh-surface:#18231e;
|
||||
--mesh-text:#e2ece5;
|
||||
--mesh-soft:#203c2b;
|
||||
--mesh-hover:#294c36;
|
||||
--mesh-border:#34483c;
|
||||
--mesh-page:#101914;
|
||||
--mesh-stripe:#1c2a22;
|
||||
--mesh-muted:#9faea6;
|
||||
--mesh-chat-stripe:#203227;
|
||||
--mesh-pulse-soft:#203c2b;
|
||||
--mesh-pulse-peak:#356b49;
|
||||
--mesh-pulse-mid:#294c36;
|
||||
--mesh-primary:#8bbcff;
|
||||
--mesh-primary-dark:#b6d5ff;
|
||||
--mesh-primary-rgb:139,188,255;
|
||||
--mesh-brand:#193d69;
|
||||
--mesh-brand-deep:#112b4b;
|
||||
--mesh-surface:#182335;
|
||||
--mesh-text:#e2eaf6;
|
||||
--mesh-soft:#203653;
|
||||
--mesh-hover:#29486c;
|
||||
--mesh-border:#34465f;
|
||||
--mesh-page:#101925;
|
||||
--mesh-stripe:#1c2a3d;
|
||||
--mesh-muted:#a0aec2;
|
||||
--mesh-chat-stripe:#20314a;
|
||||
--mesh-pulse-soft:#203653;
|
||||
--mesh-pulse-peak:#355d8e;
|
||||
--mesh-pulse-mid:#29486c;
|
||||
--bs-link-color:var(--mesh-primary);
|
||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||
--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); }
|
||||
.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; }
|
||||
|
||||
|
Before Width: | Height: | Size: 9.6 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 172 KiB |
@@ -24,47 +24,47 @@
|
||||
<style>
|
||||
:root {
|
||||
color-scheme:light;
|
||||
--mesh-primary:#187346;
|
||||
--mesh-primary-dark:#0f5132;
|
||||
--mesh-primary-rgb:24,115,70;
|
||||
--mesh-brand:#187346;
|
||||
--mesh-brand-deep:#0f5132;
|
||||
--mesh-primary:#245ea8;
|
||||
--mesh-primary-dark:#19467f;
|
||||
--mesh-primary-rgb:36,94,168;
|
||||
--mesh-brand:#245ea8;
|
||||
--mesh-brand-deep:#19467f;
|
||||
--mesh-surface:#ffffff;
|
||||
--mesh-text:#243247;
|
||||
--mesh-soft:#eaf5ee;
|
||||
--mesh-hover:#dceee2;
|
||||
--mesh-border:#dce7df;
|
||||
--mesh-page:#f5f9f6;
|
||||
--mesh-stripe:#f7faf8;
|
||||
--mesh-soft:#eaf1fb;
|
||||
--mesh-hover:#dce8f8;
|
||||
--mesh-border:#dce4ef;
|
||||
--mesh-page:#f5f8fc;
|
||||
--mesh-stripe:#f7f9fc;
|
||||
--mesh-muted:#64748b;
|
||||
--mesh-chat-stripe:#eef6f0;
|
||||
--mesh-pulse-soft:#edf8f0;
|
||||
--mesh-pulse-peak:#ccebd7;
|
||||
--mesh-pulse-mid:#e2f3e8;
|
||||
--mesh-chat-stripe:#eef3fb;
|
||||
--mesh-pulse-soft:#edf4ff;
|
||||
--mesh-pulse-peak:#c9defa;
|
||||
--mesh-pulse-mid:#deebfc;
|
||||
}
|
||||
[data-bs-theme="dark"] {
|
||||
color-scheme:dark;
|
||||
--mesh-primary:#80cfa3;
|
||||
--mesh-primary-dark:#a3e3be;
|
||||
--mesh-primary-rgb:128,207,163;
|
||||
--mesh-brand:#14532d;
|
||||
--mesh-brand-deep:#0c3a1f;
|
||||
--mesh-surface:#18231e;
|
||||
--mesh-text:#e2ece5;
|
||||
--mesh-soft:#203c2b;
|
||||
--mesh-hover:#294c36;
|
||||
--mesh-border:#34483c;
|
||||
--mesh-page:#101914;
|
||||
--mesh-stripe:#1c2a22;
|
||||
--mesh-muted:#9faea6;
|
||||
--mesh-chat-stripe:#203227;
|
||||
--mesh-pulse-soft:#203c2b;
|
||||
--mesh-pulse-peak:#356b49;
|
||||
--mesh-pulse-mid:#294c36;
|
||||
--mesh-primary:#8bbcff;
|
||||
--mesh-primary-dark:#b6d5ff;
|
||||
--mesh-primary-rgb:139,188,255;
|
||||
--mesh-brand:#193d69;
|
||||
--mesh-brand-deep:#112b4b;
|
||||
--mesh-surface:#182335;
|
||||
--mesh-text:#e2eaf6;
|
||||
--mesh-soft:#203653;
|
||||
--mesh-hover:#29486c;
|
||||
--mesh-border:#34465f;
|
||||
--mesh-page:#101925;
|
||||
--mesh-stripe:#1c2a3d;
|
||||
--mesh-muted:#a0aec2;
|
||||
--mesh-chat-stripe:#20314a;
|
||||
--mesh-pulse-soft:#203653;
|
||||
--mesh-pulse-peak:#355d8e;
|
||||
--mesh-pulse-mid:#29486c;
|
||||
--bs-link-color:var(--mesh-primary);
|
||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||
--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); }
|
||||
.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; }
|
||||
@@ -99,7 +99,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
<!-- mesh-theme:end -->
|
||||
</head>
|
||||
<body>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><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>
|
||||
<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="legend"></div>
|
||||
|
||||
|
||||
@@ -38,47 +38,47 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
<style>
|
||||
:root {
|
||||
color-scheme:light;
|
||||
--mesh-primary:#187346;
|
||||
--mesh-primary-dark:#0f5132;
|
||||
--mesh-primary-rgb:24,115,70;
|
||||
--mesh-brand:#187346;
|
||||
--mesh-brand-deep:#0f5132;
|
||||
--mesh-primary:#245ea8;
|
||||
--mesh-primary-dark:#19467f;
|
||||
--mesh-primary-rgb:36,94,168;
|
||||
--mesh-brand:#245ea8;
|
||||
--mesh-brand-deep:#19467f;
|
||||
--mesh-surface:#ffffff;
|
||||
--mesh-text:#243247;
|
||||
--mesh-soft:#eaf5ee;
|
||||
--mesh-hover:#dceee2;
|
||||
--mesh-border:#dce7df;
|
||||
--mesh-page:#f5f9f6;
|
||||
--mesh-stripe:#f7faf8;
|
||||
--mesh-soft:#eaf1fb;
|
||||
--mesh-hover:#dce8f8;
|
||||
--mesh-border:#dce4ef;
|
||||
--mesh-page:#f5f8fc;
|
||||
--mesh-stripe:#f7f9fc;
|
||||
--mesh-muted:#64748b;
|
||||
--mesh-chat-stripe:#eef6f0;
|
||||
--mesh-pulse-soft:#edf8f0;
|
||||
--mesh-pulse-peak:#ccebd7;
|
||||
--mesh-pulse-mid:#e2f3e8;
|
||||
--mesh-chat-stripe:#eef3fb;
|
||||
--mesh-pulse-soft:#edf4ff;
|
||||
--mesh-pulse-peak:#c9defa;
|
||||
--mesh-pulse-mid:#deebfc;
|
||||
}
|
||||
[data-bs-theme="dark"] {
|
||||
color-scheme:dark;
|
||||
--mesh-primary:#80cfa3;
|
||||
--mesh-primary-dark:#a3e3be;
|
||||
--mesh-primary-rgb:128,207,163;
|
||||
--mesh-brand:#14532d;
|
||||
--mesh-brand-deep:#0c3a1f;
|
||||
--mesh-surface:#18231e;
|
||||
--mesh-text:#e2ece5;
|
||||
--mesh-soft:#203c2b;
|
||||
--mesh-hover:#294c36;
|
||||
--mesh-border:#34483c;
|
||||
--mesh-page:#101914;
|
||||
--mesh-stripe:#1c2a22;
|
||||
--mesh-muted:#9faea6;
|
||||
--mesh-chat-stripe:#203227;
|
||||
--mesh-pulse-soft:#203c2b;
|
||||
--mesh-pulse-peak:#356b49;
|
||||
--mesh-pulse-mid:#294c36;
|
||||
--mesh-primary:#8bbcff;
|
||||
--mesh-primary-dark:#b6d5ff;
|
||||
--mesh-primary-rgb:139,188,255;
|
||||
--mesh-brand:#193d69;
|
||||
--mesh-brand-deep:#112b4b;
|
||||
--mesh-surface:#182335;
|
||||
--mesh-text:#e2eaf6;
|
||||
--mesh-soft:#203653;
|
||||
--mesh-hover:#29486c;
|
||||
--mesh-border:#34465f;
|
||||
--mesh-page:#101925;
|
||||
--mesh-stripe:#1c2a3d;
|
||||
--mesh-muted:#a0aec2;
|
||||
--mesh-chat-stripe:#20314a;
|
||||
--mesh-pulse-soft:#203653;
|
||||
--mesh-pulse-peak:#355d8e;
|
||||
--mesh-pulse-mid:#29486c;
|
||||
--bs-link-color:var(--mesh-primary);
|
||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||
--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); }
|
||||
.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; }
|
||||
@@ -113,7 +113,7 @@ body { background:var(--mesh-page); color:var(--mesh-text); }
|
||||
<!-- mesh-theme:end -->
|
||||
</head>
|
||||
<body>
|
||||
<button class="mesh-theme-toggle" style="position:fixed;top:12px;right:12px;z-index:2000;background:#187346" type="button" data-theme-toggle aria-label="Switch to night mode" aria-pressed="false"><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>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -86,8 +86,9 @@
|
||||
if (!recentPackets.length) { note(container, 'No recent packets found.'); return; }
|
||||
container.replaceChildren();
|
||||
for (const packet of recentPackets) {
|
||||
const button = document.createElement('a');
|
||||
button.href = `/packet/${encodeURIComponent(packetId(packet))}`;
|
||||
const hasPacketLink = Number(packet.portnum) !== 73 && Number(packetId(packet)) > 0;
|
||||
const button = document.createElement(hasPacketLink ? 'a' : 'div');
|
||||
if (hasPacketLink) button.href = `/packet/${encodeURIComponent(packetId(packet))}`;
|
||||
button.className = 'mesh-inspector-packet';
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = `${portName(packet)} · ${timeText(packet.import_time_us)}`;
|
||||
|
||||
@@ -1,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">
|
||||
<title id="title">Meshview: a mesh network through a magnifying glass</title>
|
||||
<rect width="64" height="64" rx="15" fill="#eaf5ee"/>
|
||||
<path d="M43 43 55 55" fill="none" stroke="#187346" stroke-width="7" stroke-linecap="round"/>
|
||||
<circle cx="28" cy="28" r="19" fill="none" stroke="#187346" 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"/>
|
||||
<g fill="#187346" stroke="#eaf5ee" stroke-width="1.5">
|
||||
<rect width="64" height="64" rx="15" fill="#eaf1fb"/>
|
||||
<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="#245ea8" stroke-width="4"/>
|
||||
<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="#245ea8" stroke="#eaf1fb" stroke-width="1.5">
|
||||
<circle cx="17" cy="29" r="3.8"/>
|
||||
<circle cx="29" cy="18" 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">
|
||||
<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)">
|
||||
<rect width="64" height="64" rx="15" fill="#eaf5ee"/>
|
||||
<path d="M43 43 55 55" fill="none" stroke="#187346" stroke-width="7" stroke-linecap="round"/>
|
||||
<circle cx="28" cy="28" r="19" fill="none" stroke="#187346" 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"/>
|
||||
<g fill="#187346" stroke="#eaf5ee" stroke-width="1.5">
|
||||
<rect width="64" height="64" rx="15" fill="#eaf1fb"/>
|
||||
<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="#245ea8" stroke-width="4"/>
|
||||
<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="#245ea8" stroke="#eaf1fb" stroke-width="1.5">
|
||||
<circle cx="17" cy="29" r="3.8"/>
|
||||
<circle cx="29" cy="18" r="3.8"/>
|
||||
<circle cx="38" cy="32" r="3.8"/>
|
||||
<circle cx="23" cy="38" r="3.8"/>
|
||||
</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>
|
||||
|
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.
|
||||
function chartOption(option) {
|
||||
const palette = colors();
|
||||
const text = new Set(['#333','#334155','#243247','#26364a','#e7eef7','#e2ece5']);
|
||||
const muted = new Set(['#ccc','#64748b','#888','#9faea6','#8190a5']);
|
||||
const border = new Set(['#aaa','#c3cfdf','#34483c','#dce7df']);
|
||||
const text = new Set(['#333','#334155','#243247','#26364a','#e7eef7','#e2ece5','#e2eaf6']);
|
||||
const muted = new Set(['#ccc','#64748b','#888','#9faea6','#8190a5','#a0aec2']);
|
||||
const border = new Set(['#aaa','#c3cfdf','#34483c','#dce7df','#dce4ef','#34465f']);
|
||||
function visit(value, key) {
|
||||
if (Array.isArray(value)) return value.map(item => visit(item,key));
|
||||
if (value && Object.getPrototypeOf(value) === Object.prototype) {
|
||||
@@ -30,8 +30,8 @@
|
||||
}
|
||||
if (typeof value !== 'string') return value;
|
||||
const hex = value.toLowerCase();
|
||||
if (key === 'backgroundColor' && ['#fff','#ffffff','#18231e'].includes(hex)) return palette.surface;
|
||||
if (['borderColor','textBorderColor'].includes(key) && ['#fff','#ffffff','#000','#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','#182335'].includes(hex)) return palette.surface;
|
||||
if (key === 'color' && text.has(hex)) return palette.text;
|
||||
if (key === 'color' && muted.has(hex)) return palette.muted;
|
||||
if (key === 'color' && border.has(hex)) return palette.border;
|
||||
|
||||
@@ -59,10 +59,10 @@
|
||||
--bs-link-color:var(--mesh-primary);
|
||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||
--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);
|
||||
}
|
||||
[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 {
|
||||
--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);
|
||||
@@ -100,13 +100,13 @@ input[type="checkbox"],input[type="radio"] { accent-color:var(--mesh-primary); }
|
||||
@media(prefers-reduced-motion:reduce) {
|
||||
.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-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-icon { width:32px; height:32px; display:block; flex-shrink:0; }
|
||||
#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; }
|
||||
.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; }
|
||||
@@ -123,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>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:nth-child(2) strong { color:#198754; }
|
||||
.mesh-metric:nth-child(3) strong { color:#397955; }
|
||||
.mesh-metric:nth-child(2) strong { color:var(--mesh-primary); }
|
||||
.mesh-metric:nth-child(3) strong { color:var(--mesh-primary); }
|
||||
.mesh-metric small { font-size:11px; color:var(--mesh-muted); }
|
||||
.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; }
|
||||
|
||||
@@ -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-search { width:210px; font-size:12px; border-color:var(--mesh-border); }
|
||||
.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="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); }
|
||||
@@ -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-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-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:hover { color:#0f5132; background:#d1e7dd; border-color:#69aa85; }
|
||||
.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:var(--mesh-primary-dark); background:var(--mesh-hover); border-color:var(--mesh-primary); }
|
||||
.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; }
|
||||
.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 a { font-size:12px; }
|
||||
.mesh-inspector-packet { display:block; width:100%; text-align:left; padding:10px 0; border:0; border-bottom:1px solid var(--mesh-border); background:var(--mesh-surface); color:var(--mesh-text); font-size:12px; text-decoration:none; }
|
||||
.mesh-inspector-packet:hover { background:var(--mesh-soft); }
|
||||
a.mesh-inspector-packet:hover { background:var(--mesh-soft); }
|
||||
.mesh-inspector-packet strong,.mesh-inspector-packet small { display:block; }
|
||||
.mesh-inspector-packet small { color:var(--mesh-muted); margin-top:4px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mesh-inspector-note { color:var(--mesh-muted); font-size:12px; margin:8px 0; }
|
||||
@@ -162,7 +162,7 @@
|
||||
.mesh-traffic-tools { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
.mesh-traffic-status { font-size:11px; color:var(--mesh-primary); }
|
||||
.mesh-traffic-status[data-state="error"] { color:#b23d2f; }
|
||||
.mesh-traffic-list { max-height:260px; overflow:auto; }
|
||||
.mesh-traffic-list { max-height:360px; overflow:auto; }
|
||||
.mesh-traffic-table { width:100%; min-width:780px; border-collapse:collapse; font-size:13px; color:var(--mesh-text); }
|
||||
.mesh-traffic-table th { position:sticky; top:0; z-index:1; padding:12px 14px; background:var(--mesh-brand); color:white; font-weight:500; font-size:12px; white-space:nowrap; text-align:left; }
|
||||
.mesh-traffic-table td { padding:11px 14px; border-bottom:1px solid var(--mesh-border); text-align:left; }
|
||||
|
||||
@@ -98,6 +98,7 @@
|
||||
<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_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="mqtt_gateway">MQTT <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
|
||||
@@ -106,7 +107,7 @@
|
||||
</thead>
|
||||
<tbody id="node-table-body">
|
||||
<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...
|
||||
</td>
|
||||
</tr>
|
||||
@@ -169,7 +170,7 @@ const minStatusMs = 300;
|
||||
const headers = document.querySelectorAll("thead th");
|
||||
const keyMap = [
|
||||
"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) {
|
||||
@@ -300,7 +301,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
await loadNodesForActiveDays();
|
||||
} catch (err) {
|
||||
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"}
|
||||
</td></tr>`;
|
||||
setStatus("");
|
||||
@@ -326,7 +327,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
} catch (err) {
|
||||
console.error("Failed to reload nodes:", err);
|
||||
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"}
|
||||
</td></tr>`;
|
||||
mobileList.innerHTML = "";
|
||||
@@ -474,7 +475,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
if (!nodes.length) {
|
||||
if (shouldRenderTable) {
|
||||
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"}
|
||||
</td>
|
||||
</tr>`;
|
||||
@@ -503,6 +504,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
<td>${escapeHtml(node.role || "N/A")}</td>
|
||||
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.last_altitude != null ? escapeHtml(String(node.last_altitude)) : "N/A"}</td>
|
||||
<td>${escapeHtml(node.channel || "N/A")}</td>
|
||||
<td>${node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No")}</td>
|
||||
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
|
||||
@@ -534,6 +536,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
|
||||
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
|
||||
</div>
|
||||
<div class="node-card-field"><b>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>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
|
||||
|
||||
@@ -584,6 +587,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
node.firmware || "N/A", node.role || "N/A",
|
||||
node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A",
|
||||
node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A",
|
||||
node.last_altitude ?? "N/A",
|
||||
node.channel || "N/A",
|
||||
node.is_mqtt_gateway ? (nodelistTranslations.yes || "Yes") : (nodelistTranslations.no || "No"),
|
||||
timeAgoFromMs(node.last_seen_ms), isFavorite(node.node_id) ? "★" : "☆"
|
||||
@@ -604,6 +608,12 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
let A = a[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") {
|
||||
A = A || 0;
|
||||
B = B || 0;
|
||||
|
||||
@@ -10,9 +10,9 @@
|
||||
.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-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(3) .summary-count { color:#236b4b; }
|
||||
.summary-card:nth-child(4) .summary-count { color:#497b55; }
|
||||
.summary-card:nth-child(2) .summary-count { color:var(--mesh-primary); }
|
||||
.summary-card:nth-child(3) .summary-count { color:var(--mesh-primary); }
|
||||
.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; }
|
||||
.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 { 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); }
|
||||
@@ -22,6 +22,17 @@
|
||||
#chartModal { display:none; position:fixed; inset:0; background:#17263c99; z-index:9999; justify-content:center; align-items:center; padding:16px; }
|
||||
#chartModal > div { position:relative; width:95%; max-width:1200px; height:85%; background:var(--mesh-surface); border-radius:10px; padding:48px 16px 16px; display:flex; flex-direction:column; }
|
||||
#chartModal #closeModal { position:absolute; top:10px; right:12px; font-size:22px; padding:0; width:32px; height:32px; line-height:1; }
|
||||
#altitude-map-button { margin-left:auto; }
|
||||
#altitude-map-modal[hidden] { display:none; }
|
||||
#altitude-map-modal { position:fixed; inset:0; z-index:10000; display:flex; align-items:center; justify-content:center; padding:16px; background:#17263c99; }
|
||||
.altitude-map-dialog { width:min(1100px,100%); height:85dvh; display:flex; flex-direction:column; background:var(--mesh-surface); border:1px solid var(--mesh-border); border-radius:10px; padding:16px; }
|
||||
.altitude-map-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
|
||||
.altitude-map-heading h2 { font-size:18px; margin:0; }
|
||||
#altitude-map-status { color:var(--mesh-muted); font-size:13px; margin:10px 0; }
|
||||
#altitude-router-map { flex:1; min-height:0; border-radius:6px; }
|
||||
.altitude-router-icon { background:transparent; border:0; }
|
||||
.altitude-router-icon svg { width:32px; height:32px; filter:drop-shadow(0 2px 3px #0005); }
|
||||
.altitude-router-label { background:var(--mesh-surface); color:var(--mesh-text); border-color:var(--mesh-border); box-shadow:none; font-size:9px; line-height:1.2; padding:2px 4px; }
|
||||
#modalChart { flex:1; min-height:0; width:100%; }
|
||||
@media(max-width:1000px) { .stats-chart-grid { grid-template-columns:minmax(0,1fr); } }
|
||||
@media(max-width:700px) { .summary-container { grid-template-columns:repeat(2,minmax(0,1fr)); gap:10px; } .summary-card { padding:16px; } .summary-count { font-size:26px; } .card-section { padding:14px; gap:8px; } .chart { height:320px; } }
|
||||
@@ -29,6 +40,8 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css">
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||
<script src="/static/portmaps.js"></script>
|
||||
{% endblock %}
|
||||
@@ -97,7 +110,7 @@
|
||||
<button id="text-nodes-refresh" type="button">Refresh sample</button>
|
||||
<span class="total-count" id="text-nodes-status" role="status">Loading…</span>
|
||||
<button class="expand-btn" data-chart="chart_text_nodes">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_text_nodes">Export CSV</button>
|
||||
<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>
|
||||
@@ -107,9 +120,10 @@
|
||||
<button id="altitude-refresh" type="button">Refresh report</button>
|
||||
<span class="total-count" id="altitude-status" role="status">Loading…</span>
|
||||
<button class="expand-btn" data-chart="chart_altitude">Expand Chart</button>
|
||||
<button class="export-btn" data-chart="chart_altitude">Export CSV</button>
|
||||
<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 with known altitude are included. Click a bar to view the node.</p>
|
||||
<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 -->
|
||||
@@ -202,6 +216,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="altitude-map-modal" role="dialog" aria-modal="true" aria-labelledby="altitude-map-title" hidden>
|
||||
<div class="altitude-map-dialog">
|
||||
<div class="altitude-map-heading"><h2 id="altitude-map-title">highest routers · top 50</h2><button id="altitude-map-close" type="button" aria-label="Close router map">×</button></div>
|
||||
<p id="altitude-map-status" role="status"></p>
|
||||
<div id="altitude-router-map" aria-label="Map of highest routers and their altitudes"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal for expanded charts -->
|
||||
<div id="chartModal" role="dialog" aria-modal="true" aria-label="Expanded chart">
|
||||
<div>
|
||||
@@ -215,6 +237,7 @@ 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();
|
||||
@@ -245,6 +268,7 @@ async function loadTextNodes() {
|
||||
const chart = initMeshChart(document.getElementById("chart_text_nodes"));
|
||||
status.textContent = "Loading…";
|
||||
textNodeRows = [];
|
||||
textNodeExportRows = [];
|
||||
chart.clear();
|
||||
chart.off("click");
|
||||
chart.on("click", openTextSender);
|
||||
@@ -259,7 +283,8 @@ async function loadTextNodes() {
|
||||
if (request !== textNodesRequest) return;
|
||||
const sample = data.packets.slice(0, 500);
|
||||
const ranked = rankMainChannelTextSenders(sample, nodes);
|
||||
textNodeRows = ranked.slice(0, 10);
|
||||
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`
|
||||
@@ -297,12 +322,12 @@ document.getElementById("text-nodes-refresh").addEventListener("click", loadText
|
||||
loadTextNodes();
|
||||
|
||||
let altitudeRows = [];
|
||||
let altitudeExportRows = [];
|
||||
function rankAltitudeNodes(nodes) {
|
||||
return nodes
|
||||
.filter(node => Number.isFinite(node.last_altitude) && node.node_id != null
|
||||
.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)
|
||||
.slice(0, 10);
|
||||
.sort((a, b) => b.last_altitude - a.last_altitude || a.node_id - b.node_id);
|
||||
}
|
||||
function openAltitudeNode(event) {
|
||||
const node = altitudeRows[event.dataIndex];
|
||||
@@ -316,11 +341,14 @@ async function loadAltitudeNodes(refreshNodes = false) {
|
||||
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 {
|
||||
altitudeRows = rankAltitudeNodes(await fetchStatsNodes(refreshNodes));
|
||||
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},
|
||||
@@ -328,14 +356,16 @@ async function loadAltitudeNodes(refreshNodes = false) {
|
||||
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"},
|
||||
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 yet.";
|
||||
: "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);
|
||||
@@ -346,6 +376,73 @@ async function loadAltitudeNodes(refreshNodes = false) {
|
||||
document.getElementById("altitude-refresh").addEventListener("click", () => loadAltitudeNodes(true));
|
||||
loadAltitudeNodes();
|
||||
|
||||
// Map the same ranked sample used by the top-50 CSV export.
|
||||
let altitudeMap;
|
||||
let altitudeMarkers;
|
||||
const altitudeMapModal = document.getElementById("altitude-map-modal");
|
||||
const altitudeMapButton = document.getElementById("altitude-map-button");
|
||||
const altitudeMapClose = document.getElementById("altitude-map-close");
|
||||
function closeAltitudeMap() {
|
||||
altitudeMapModal.hidden = true;
|
||||
altitudeMapButton.focus();
|
||||
}
|
||||
altitudeMapClose.addEventListener("click", closeAltitudeMap);
|
||||
altitudeMapModal.addEventListener("click", event => {
|
||||
if (event.target === altitudeMapModal) closeAltitudeMap();
|
||||
});
|
||||
altitudeMapModal.addEventListener("keydown", event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); closeAltitudeMap(); }
|
||||
if (event.key === "Tab") {
|
||||
const controls = [...altitudeMapModal.querySelectorAll('button, a[href], [tabindex="0"]')]
|
||||
.filter(el => el.getClientRects().length);
|
||||
const first = controls[0], last = controls[controls.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
||||
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
||||
}
|
||||
});
|
||||
altitudeMapButton.addEventListener("click", () => {
|
||||
altitudeMapModal.hidden = false;
|
||||
altitudeMapClose.focus();
|
||||
if (!altitudeMap) {
|
||||
altitudeMap = L.map("altitude-router-map").setView([0,0], 2);
|
||||
L.tileLayer("https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png", {
|
||||
attribution:'Map data: © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, SRTM | Map style: © <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)',
|
||||
maxZoom:17
|
||||
}).addTo(altitudeMap);
|
||||
altitudeMarkers = L.layerGroup().addTo(altitudeMap);
|
||||
}
|
||||
altitudeMarkers.clearLayers();
|
||||
const located = altitudeExportRows.filter(node =>
|
||||
Number.isFinite(node.last_lat) && Number.isFinite(node.last_long)
|
||||
&& Math.abs(node.last_lat) <= 900000000 && Math.abs(node.last_long) <= 1800000000
|
||||
&& (Math.abs(node.last_lat) > 10000000 || Math.abs(node.last_long) > 10000000));
|
||||
const icon = L.divIcon({className:"altitude-router-icon", iconSize:[32,32], iconAnchor:[16,16],
|
||||
html:'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" aria-hidden="true"><circle cx="16" cy="16" r="15" fill="#187346" stroke="white" stroke-width="2"/><g fill="none" stroke="white" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 21h12v4H10zM16 21v-7m-4 1a6 6 0 0 1 8 0m-11-3a10 10 0 0 1 14 0"/></g><circle cx="16" cy="12" r="1.5" fill="white"/></svg>'});
|
||||
located.forEach(node => {
|
||||
const marker = L.marker([node.last_lat / 1e7,node.last_long / 1e7], {icon}).addTo(altitudeMarkers);
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `${node.short_name || node.long_name || node.id} · ${node.last_altitude.toLocaleString()} m`;
|
||||
marker.bindTooltip(label, {permanent:true, direction:"top", offset:[0,-16], className:"altitude-router-label"});
|
||||
const details = document.createElement("div");
|
||||
const link = document.createElement("a");
|
||||
link.href = `/node/${encodeURIComponent(node.node_id)}`;
|
||||
link.textContent = node.long_name || node.short_name || node.id;
|
||||
const altitude = document.createElement("p");
|
||||
altitude.textContent = `${node.last_altitude.toLocaleString()} m above sea level · ${node.role}`;
|
||||
details.append(link,altitude);
|
||||
marker.bindPopup(details);
|
||||
});
|
||||
const missing = altitudeExportRows.length - located.length;
|
||||
document.getElementById("altitude-map-status").textContent =
|
||||
`Showing ${located.length} of the top ${altitudeExportRows.length} routers · altitude in meters above sea level` +
|
||||
(missing ? ` · ${missing} without a valid location` : '');
|
||||
requestAnimationFrame(() => {
|
||||
altitudeMap.invalidateSize();
|
||||
if (located.length) altitudeMap.fitBounds(located.map(node => [node.last_lat / 1e7,node.last_long / 1e7]), {padding:[60,60], maxZoom:12});
|
||||
else altitudeMap.setView([0,0],2);
|
||||
});
|
||||
});
|
||||
|
||||
// --- Fetch & Processing ---
|
||||
async function fetchStats(period_type,length,portnum=null,channel=null){
|
||||
try{
|
||||
@@ -823,13 +920,13 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
|
||||
let rows=[];
|
||||
if (chartId === "chart_altitude") {
|
||||
rows = [["Node ID", "Short Name", "Long Name", "Altitude (m above sea level)"],
|
||||
...altitudeRows.map(node => [node.node_id, node.short_name || "", node.long_name || "", node.last_altitude])];
|
||||
...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"],
|
||||
...textNodeRows.map(row => [row.node_id, row.short_name || "", row.long_name || "", row.channel, row.packet_count])];
|
||||
...textNodeExportRows.map(row => [row.node_id, row.short_name || "", row.long_name || "", row.channel, row.packet_count])];
|
||||
downloadCSV(chartId + ".csv", rows);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,47 @@
|
||||
<style>
|
||||
:root {
|
||||
color-scheme:light;
|
||||
--mesh-primary:#187346;
|
||||
--mesh-primary-dark:#0f5132;
|
||||
--mesh-primary-rgb:24,115,70;
|
||||
--mesh-brand:#187346;
|
||||
--mesh-brand-deep:#0f5132;
|
||||
--mesh-primary:#245ea8;
|
||||
--mesh-primary-dark:#19467f;
|
||||
--mesh-primary-rgb:36,94,168;
|
||||
--mesh-brand:#245ea8;
|
||||
--mesh-brand-deep:#19467f;
|
||||
--mesh-surface:#ffffff;
|
||||
--mesh-text:#243247;
|
||||
--mesh-soft:#eaf5ee;
|
||||
--mesh-hover:#dceee2;
|
||||
--mesh-border:#dce7df;
|
||||
--mesh-page:#f5f9f6;
|
||||
--mesh-stripe:#f7faf8;
|
||||
--mesh-soft:#eaf1fb;
|
||||
--mesh-hover:#dce8f8;
|
||||
--mesh-border:#dce4ef;
|
||||
--mesh-page:#f5f8fc;
|
||||
--mesh-stripe:#f7f9fc;
|
||||
--mesh-muted:#64748b;
|
||||
--mesh-chat-stripe:#eef6f0;
|
||||
--mesh-pulse-soft:#edf8f0;
|
||||
--mesh-pulse-peak:#ccebd7;
|
||||
--mesh-pulse-mid:#e2f3e8;
|
||||
--mesh-chat-stripe:#eef3fb;
|
||||
--mesh-pulse-soft:#edf4ff;
|
||||
--mesh-pulse-peak:#c9defa;
|
||||
--mesh-pulse-mid:#deebfc;
|
||||
}
|
||||
[data-bs-theme="dark"] {
|
||||
color-scheme:dark;
|
||||
--mesh-primary:#80cfa3;
|
||||
--mesh-primary-dark:#a3e3be;
|
||||
--mesh-primary-rgb:128,207,163;
|
||||
--mesh-brand:#14532d;
|
||||
--mesh-brand-deep:#0c3a1f;
|
||||
--mesh-surface:#18231e;
|
||||
--mesh-text:#e2ece5;
|
||||
--mesh-soft:#203c2b;
|
||||
--mesh-hover:#294c36;
|
||||
--mesh-border:#34483c;
|
||||
--mesh-page:#101914;
|
||||
--mesh-stripe:#1c2a22;
|
||||
--mesh-muted:#9faea6;
|
||||
--mesh-chat-stripe:#203227;
|
||||
--mesh-pulse-soft:#203c2b;
|
||||
--mesh-pulse-peak:#356b49;
|
||||
--mesh-pulse-mid:#294c36;
|
||||
--mesh-primary:#8bbcff;
|
||||
--mesh-primary-dark:#b6d5ff;
|
||||
--mesh-primary-rgb:139,188,255;
|
||||
--mesh-brand:#193d69;
|
||||
--mesh-brand-deep:#112b4b;
|
||||
--mesh-surface:#182335;
|
||||
--mesh-text:#e2eaf6;
|
||||
--mesh-soft:#203653;
|
||||
--mesh-hover:#29486c;
|
||||
--mesh-border:#34465f;
|
||||
--mesh-page:#101925;
|
||||
--mesh-stripe:#1c2a3d;
|
||||
--mesh-muted:#a0aec2;
|
||||
--mesh-chat-stripe:#20314a;
|
||||
--mesh-pulse-soft:#203653;
|
||||
--mesh-pulse-peak:#355d8e;
|
||||
--mesh-pulse-mid:#29486c;
|
||||
--bs-link-color:var(--mesh-primary);
|
||||
--bs-link-color-rgb:var(--mesh-primary-rgb);
|
||||
--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); }
|
||||
.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; }
|
||||
|
||||