5 Commits
Author SHA1 Message Date
pablorevilla-meshtastic e1e34c120e update 2026-10-29 16:05:47 -07:00
pablorevilla-meshtastic 63abac5448 added new map 2026-10-29 16:04:24 -07:00
pablorevilla-meshtastic 65fb307d07 fixed node inspector 2026-10-29 15:37:52 -07:00
pablorevilla-meshtastic f5d5dd6049 more updates 2026-10-29 13:10:17 -07:00
pablorevilla-meshtastic 8446ef8956 fixed colors in report 2026-10-29 10:47:51 -07:00
4 changed files with 257 additions and 144 deletions

No files matched your search

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