mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52f1a1e788 | ||
|
|
f44a78730a | ||
|
|
a9a5e046ea | ||
|
|
37386f9e28 | ||
|
|
b66bfb1ee9 | ||
|
|
caf9cd1596 | ||
|
|
a4ebd2b23c | ||
|
|
5676ade6b7 | ||
|
|
319f8eac06 | ||
|
|
d85132133a | ||
|
|
b6d8af409c | ||
|
|
896a0980d5 | ||
|
|
7d395e5e27 | ||
|
|
c3cc01d7e7 | ||
|
|
ecbadc6087 | ||
|
|
ff30623bdf | ||
|
|
a43433ccb4 | ||
|
|
4d9db2a52c | ||
|
|
e30b59851f | ||
|
|
36dd91be63 | ||
|
|
c9639d851b | ||
|
|
4516c84128 | ||
|
|
fa98f56318 | ||
|
|
f85e783e8c | ||
|
|
a882bc22dd | ||
|
|
e12e3a2a41 | ||
|
|
da31794d8d | ||
|
|
9912f6b181 | ||
|
|
cb4cc281c6 | ||
|
|
571559114d | ||
|
|
df26df07f1 | ||
|
|
ffc7340bc9 | ||
|
|
1d58aaba83 | ||
|
|
b2bb9345fe | ||
|
|
9686622b56 | ||
|
|
f7644a9573 | ||
|
|
e48e9464d7 | ||
|
|
b72bc5d52b | ||
|
|
1220f0bcbd | ||
|
|
539410d5bb | ||
|
|
383b576d18 | ||
|
|
64a55a3ef3 | ||
|
|
9408201e57 | ||
|
|
f75d6bf749 | ||
|
|
924d223866 | ||
|
|
e9dcca1f19 | ||
|
|
00cc2abd23 | ||
|
|
b76477167d | ||
|
|
b41b249a6d | ||
|
|
71fcda2dd6 | ||
|
|
c4453fbb31 | ||
|
|
79fa3f66a8 | ||
|
|
0ce64ac975 |
6
.github/workflows/container.yml
vendored
6
.github/workflows/container.yml
vendored
@@ -17,13 +17,15 @@ jobs:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
# latest tag is only set for semver/tag-based builds (default behavior)
|
||||
flavor: |
|
||||
latest=auto
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=v\d.\d.\d,value=latest
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
@@ -49,4 +51,4 @@ jobs:
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# optional cache (speeds up rebuilds)
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -45,3 +45,4 @@ __pycache__/
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
packets.db-journal
|
||||
|
||||
@@ -35,7 +35,7 @@ RUN uv pip install --no-cache-dir --upgrade pip \
|
||||
COPY --chown=${APP_USER}:${APP_USER} . .
|
||||
|
||||
# Patch config
|
||||
RUN patch sample.config.ini < container/config.patch
|
||||
RUN patch -p1 < container/config.patch
|
||||
|
||||
# Clean
|
||||
RUN rm -rf /app/.git* && \
|
||||
@@ -77,4 +77,3 @@ CMD ["--pid_dir", "/tmp", "--py_exec", "/opt/venv/bin/python", "--config", "/etc
|
||||
|
||||
EXPOSE 8081
|
||||
VOLUME [ "/etc/meshview", "/var/lib/meshview", "/var/log/meshview" ]
|
||||
|
||||
|
||||
@@ -128,7 +128,11 @@ username =
|
||||
password =
|
||||
|
||||
[database]
|
||||
connection_string = sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
# 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
|
||||
```
|
||||
|
||||
### Database Backups
|
||||
|
||||
124
README.md
124
README.md
@@ -4,6 +4,13 @@
|
||||
|
||||
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into network activity, including message traffic, node positions, and telemetry data.
|
||||
|
||||
### Version 3.0.3 — January 2026
|
||||
- Added database support for MySQL (not tested, would love to have someone test!) and PostgreSQL (alongside SQLite) for larger or shared deployments.
|
||||
- Configuration updated to allow selecting the database backend via connection string.
|
||||
|
||||
### Version 3.0.2 — January 2026
|
||||
- Changes to the Database to will make it so that there is a need for space when updating to the latest. SQlite requires to rebuild the database when droping a column. ( we are droping some of the old columns) so make sure you have 1.2x the size of the db of space in your environment. Depending on how big your db is it would take a long time.
|
||||
|
||||
### Version 3.0.1 — December 2025
|
||||
|
||||
#### 🌐 Multi-Language Support (i18n)
|
||||
@@ -82,22 +89,27 @@ Samples of currently running instances:
|
||||
|
||||
- https://meshview.bayme.sh (SF Bay Area)
|
||||
- https://www.svme.sh (Sacramento Valley)
|
||||
- https://meshview.nyme.sh (New York)
|
||||
- https://meshview.nyme.sh (New York)
|
||||
- https://meshview.socalmesh.org (LA Area)
|
||||
- https://map.wpamesh.net (Western Pennsylvania)
|
||||
- https://meshview.chicagolandmesh.org (Chicago)
|
||||
- https://meshview.mt.gt (Canadaverse)
|
||||
- https://meshview.mt.gt (Canada)
|
||||
- https://canadaverse.org (Canada)
|
||||
- https://meshview.meshtastic.es (Spain)
|
||||
- https://view.mtnme.sh (North Georgia / East Tennessee)
|
||||
- https://meshview.lsinfra.de (Hessen - Germany)
|
||||
- https://map.nswmesh.au (Sydney - Australia)
|
||||
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
|
||||
- https://meshview.louisianamesh.org (Louisiana)
|
||||
- https://meshview.meshcolombia.co/ (Colombia)
|
||||
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
|
||||
- https://www.swlamesh.com (Southwest Louisiana)
|
||||
- https://meshview.meshcolombia.co (Colombia)
|
||||
- https://meshview-salzburg.jmt.gr (Salzburg / Austria)
|
||||
- https://map.cromesh.eu (Coatia)
|
||||
- https://view.meshdresden.eu (Dresden / Germany)
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
### Updating from 2.x to 3.x
|
||||
We are adding the use of Alembic. If using GitHub
|
||||
Update your codebase by running the pull command
|
||||
@@ -271,7 +283,10 @@ password = large4cats
|
||||
# Database Configuration
|
||||
# -------------------------
|
||||
[database]
|
||||
# SQLAlchemy connection string. This one uses SQLite with asyncio support.
|
||||
# SQLAlchemy async connection string.
|
||||
# Examples:
|
||||
# sqlite+aiosqlite:///packets.db
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:///packets.db
|
||||
|
||||
|
||||
@@ -305,6 +320,20 @@ db_cleanup_logfile = dbcleanup.log
|
||||
|
||||
---
|
||||
|
||||
## NOTE (PostgreSQL setup)**
|
||||
If you want to use PostgreSQL instead of SQLite:
|
||||
|
||||
Install PostgreSQL for your OS.
|
||||
Create a user and database:
|
||||
```
|
||||
`CREATE USER meshview WITH PASSWORD 'change_me';`
|
||||
`CREATE DATABASE meshview OWNER meshview;`
|
||||
```
|
||||
Update `config.ini` example:
|
||||
```
|
||||
`connection_string = postgresql+asyncpg://meshview:change_me@localhost:5432/meshview`
|
||||
```
|
||||
|
||||
## Running Meshview
|
||||
|
||||
Start the database manager:
|
||||
@@ -474,16 +503,15 @@ db_cleanup_logfile = dbcleanup.log
|
||||
```
|
||||
Once changes are done you need to restart the script for changes to load.
|
||||
|
||||
### Alternatively we can do it via your OS
|
||||
### Alternatively we can do it via your OS (This example is Ubuntu like OS)
|
||||
- Create and save bash script below. (Modify /path/to/file/ to the correct path)
|
||||
- Name it cleanup.sh
|
||||
- Make it executable.
|
||||
```bash
|
||||
#!/bin/bash
|
||||
#!/bin/bash
|
||||
|
||||
DB_FILE="/path/to/file/packets.db"
|
||||
|
||||
|
||||
# Stop DB service
|
||||
sudo systemctl stop meshview-db.service
|
||||
sudo systemctl stop meshview-web.service
|
||||
@@ -492,10 +520,22 @@ sleep 5
|
||||
echo "Run cleanup..."
|
||||
# Run cleanup queries
|
||||
sqlite3 "$DB_FILE" <<EOF
|
||||
DELETE FROM packet WHERE import_time < datetime('now', '-14 day');
|
||||
DELETE FROM packet_seen WHERE import_time < datetime('now', '-14 day');
|
||||
DELETE FROM traceroute WHERE import_time < datetime('now', '-14 day');
|
||||
DELETE FROM node WHERE last_update < datetime('now', '-14 day') OR last_update IS NULL OR last_update = '';
|
||||
DELETE FROM packet
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (strftime('%s','now','-14 days') * 1000000);
|
||||
SELECT 'packet deleted: ' || changes();
|
||||
DELETE FROM packet_seen
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (strftime('%s','now','-14 days') * 1000000);
|
||||
SELECT 'packet_seen deleted: ' || changes();
|
||||
DELETE FROM traceroute
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (strftime('%s','now','-14 days') * 1000000);
|
||||
SELECT 'traceroute deleted: ' || changes();
|
||||
DELETE FROM node
|
||||
WHERE last_seen_us IS NULL
|
||||
OR last_seen_us < (strftime('%s','now','-14 days') * 1000000);
|
||||
SELECT 'node deleted: ' || changes();
|
||||
VACUUM;
|
||||
EOF
|
||||
|
||||
@@ -505,6 +545,64 @@ sudo systemctl start meshview-web.service
|
||||
|
||||
echo "Database cleanup completed on $(date)"
|
||||
|
||||
```
|
||||
- If you are using PostgreSQL, use this version instead (adjust credentials/DB name):
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
DB_NAME="meshview"
|
||||
DB_USER="meshview"
|
||||
DB_HOST="localhost"
|
||||
DB_PORT="5432"
|
||||
|
||||
# Stop DB service
|
||||
sudo systemctl stop meshview-db.service
|
||||
sudo systemctl stop meshview-web.service
|
||||
|
||||
sleep 5
|
||||
echo "Run cleanup..."
|
||||
# Run cleanup queries
|
||||
psql "postgresql://${DB_USER}@${DB_HOST}:${DB_PORT}/${DB_NAME}" <<'EOF'
|
||||
WITH deleted AS (
|
||||
DELETE FROM packet
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'packet deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
WITH deleted AS (
|
||||
DELETE FROM packet_seen
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'packet_seen deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
WITH deleted AS (
|
||||
DELETE FROM traceroute
|
||||
WHERE import_time_us IS NOT NULL
|
||||
AND import_time_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'traceroute deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
WITH deleted AS (
|
||||
DELETE FROM node
|
||||
WHERE last_seen_us IS NULL
|
||||
OR last_seen_us < (EXTRACT(EPOCH FROM (NOW() - INTERVAL '14 days')) * 1000000)
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT 'node deleted: ' || COUNT(*) FROM deleted;
|
||||
|
||||
VACUUM;
|
||||
EOF
|
||||
|
||||
# Start DB service
|
||||
sudo systemctl start meshview-db.service
|
||||
sudo systemctl start meshview-web.service
|
||||
|
||||
echo "Database cleanup completed on $(date)"
|
||||
```
|
||||
- Schedule running the script on a regular basis.
|
||||
- In this example it runs every night at 2:00am.
|
||||
|
||||
65
alembic/versions/9f3b1a8d2c4f_drop_import_time_columns.py
Normal file
65
alembic/versions/9f3b1a8d2c4f_drop_import_time_columns.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Drop import_time columns.
|
||||
|
||||
Revision ID: 9f3b1a8d2c4f
|
||||
Revises: 2b5a61bb2b75
|
||||
Create Date: 2026-01-09 09:55:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "9f3b1a8d2c4f"
|
||||
down_revision: str | None = "2b5a61bb2b75"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
packet_indexes = {idx["name"] for idx in inspector.get_indexes("packet")}
|
||||
packet_columns = {col["name"] for col in inspector.get_columns("packet")}
|
||||
|
||||
with op.batch_alter_table("packet", schema=None) as batch_op:
|
||||
if "idx_packet_import_time" in packet_indexes:
|
||||
batch_op.drop_index("idx_packet_import_time")
|
||||
if "idx_packet_from_node_time" in packet_indexes:
|
||||
batch_op.drop_index("idx_packet_from_node_time")
|
||||
if "import_time" in packet_columns:
|
||||
batch_op.drop_column("import_time")
|
||||
|
||||
packet_seen_columns = {col["name"] for col in inspector.get_columns("packet_seen")}
|
||||
with op.batch_alter_table("packet_seen", schema=None) as batch_op:
|
||||
if "import_time" in packet_seen_columns:
|
||||
batch_op.drop_column("import_time")
|
||||
|
||||
traceroute_indexes = {idx["name"] for idx in inspector.get_indexes("traceroute")}
|
||||
traceroute_columns = {col["name"] for col in inspector.get_columns("traceroute")}
|
||||
with op.batch_alter_table("traceroute", schema=None) as batch_op:
|
||||
if "idx_traceroute_import_time" in traceroute_indexes:
|
||||
batch_op.drop_index("idx_traceroute_import_time")
|
||||
if "import_time" in traceroute_columns:
|
||||
batch_op.drop_column("import_time")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("traceroute", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("import_time", sa.DateTime(), nullable=True))
|
||||
batch_op.create_index("idx_traceroute_import_time", ["import_time"], unique=False)
|
||||
|
||||
with op.batch_alter_table("packet_seen", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("import_time", sa.DateTime(), nullable=True))
|
||||
|
||||
with op.batch_alter_table("packet", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("import_time", sa.DateTime(), nullable=True))
|
||||
batch_op.create_index("idx_packet_import_time", [sa.text("import_time DESC")], unique=False)
|
||||
batch_op.create_index(
|
||||
"idx_packet_from_node_time",
|
||||
["from_node_id", sa.text("import_time DESC")],
|
||||
unique=False,
|
||||
)
|
||||
94
alembic/versions/b7c3c2e3a1f0_add_last_update_us_to_node.py
Normal file
94
alembic/versions/b7c3c2e3a1f0_add_last_update_us_to_node.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""Add last_update_us to node and migrate data.
|
||||
|
||||
Revision ID: b7c3c2e3a1f0
|
||||
Revises: 9f3b1a8d2c4f
|
||||
Create Date: 2026-01-12 10:12:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b7c3c2e3a1f0"
|
||||
down_revision: str | None = "9f3b1a8d2c4f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _parse_datetime(value):
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
dt = value
|
||||
elif isinstance(value, str):
|
||||
text = value.replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=UTC)
|
||||
return dt.astimezone(UTC)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
op.add_column("node", sa.Column("last_update_us", sa.BigInteger(), nullable=True))
|
||||
op.create_index("idx_node_last_update_us", "node", ["last_update_us"], unique=False)
|
||||
|
||||
node = sa.table(
|
||||
"node",
|
||||
sa.column("id", sa.String()),
|
||||
sa.column("last_update", sa.DateTime()),
|
||||
sa.column("last_update_us", sa.BigInteger()),
|
||||
)
|
||||
|
||||
rows = conn.execute(sa.select(node.c.id, node.c.last_update)).all()
|
||||
for node_id, last_update in rows:
|
||||
dt = _parse_datetime(last_update)
|
||||
if dt is None:
|
||||
continue
|
||||
last_update_us = int(dt.timestamp() * 1_000_000)
|
||||
conn.execute(
|
||||
sa.update(node).where(node.c.id == node_id).values(last_update_us=last_update_us)
|
||||
)
|
||||
|
||||
if conn.dialect.name == "sqlite":
|
||||
with op.batch_alter_table("node", schema=None) as batch_op:
|
||||
batch_op.drop_column("last_update")
|
||||
else:
|
||||
op.drop_column("node", "last_update")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
op.add_column("node", sa.Column("last_update", sa.DateTime(), nullable=True))
|
||||
|
||||
node = sa.table(
|
||||
"node",
|
||||
sa.column("id", sa.String()),
|
||||
sa.column("last_update", sa.DateTime()),
|
||||
sa.column("last_update_us", sa.BigInteger()),
|
||||
)
|
||||
|
||||
rows = conn.execute(sa.select(node.c.id, node.c.last_update_us)).all()
|
||||
for node_id, last_update_us in rows:
|
||||
if last_update_us is None:
|
||||
continue
|
||||
dt = datetime.fromtimestamp(last_update_us / 1_000_000, tz=UTC).replace(tzinfo=None)
|
||||
conn.execute(sa.update(node).where(node.c.id == node_id).values(last_update=dt))
|
||||
|
||||
if conn.dialect.name == "sqlite":
|
||||
with op.batch_alter_table("node", schema=None) as batch_op:
|
||||
batch_op.drop_index("idx_node_last_update_us")
|
||||
batch_op.drop_column("last_update_us")
|
||||
else:
|
||||
op.drop_index("idx_node_last_update_us", table_name="node")
|
||||
op.drop_column("node", "last_update_us")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""Drop last_update_us from node.
|
||||
|
||||
Revision ID: d4d7b0c2e1a4
|
||||
Revises: b7c3c2e3a1f0
|
||||
Create Date: 2026-01-12 10:20:00.000000
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "d4d7b0c2e1a4"
|
||||
down_revision: str | None = "b7c3c2e3a1f0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
if conn.dialect.name == "sqlite":
|
||||
with op.batch_alter_table("node", schema=None) as batch_op:
|
||||
batch_op.drop_index("idx_node_last_update_us")
|
||||
batch_op.drop_column("last_update_us")
|
||||
else:
|
||||
op.drop_index("idx_node_last_update_us", table_name="node")
|
||||
op.drop_column("node", "last_update_us")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("node", sa.Column("last_update_us", sa.BigInteger(), nullable=True))
|
||||
op.create_index("idx_node_last_update_us", "node", ["last_update_us"], unique=False)
|
||||
@@ -1,82 +1,38 @@
|
||||
|
||||
# API Documentation
|
||||
|
||||
## 1. Chat API
|
||||
Base URL: `http(s)://<host>`
|
||||
|
||||
### GET `/api/chat`
|
||||
Returns the most recent chat messages.
|
||||
All endpoints return JSON. Timestamps are either ISO 8601 strings or `*_us` values in
|
||||
microseconds since epoch.
|
||||
|
||||
**Query Parameters**
|
||||
- `limit` (optional, int): Maximum number of messages to return. Default: `100`.
|
||||
|
||||
**Response Example**
|
||||
```json
|
||||
{
|
||||
"packets": [
|
||||
{
|
||||
"id": 123,
|
||||
"import_time": "2025-07-22T12:45:00",
|
||||
"from_node_id": 987654,
|
||||
"from_node": "Alice",
|
||||
"channel": "main",
|
||||
"payload": "Hello, world!"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/chat/updates`
|
||||
Returns chat messages imported after a given timestamp.
|
||||
|
||||
**Query Parameters**
|
||||
- `last_time` (optional, ISO timestamp): Only messages imported after this time are returned.
|
||||
|
||||
**Response Example**
|
||||
```json
|
||||
{
|
||||
"packets": [
|
||||
{
|
||||
"id": 124,
|
||||
"import_time": "2025-07-22T12:50:00",
|
||||
"from_node_id": 987654,
|
||||
"from_node": "Alice",
|
||||
"channel": "main",
|
||||
"payload": "New message!"
|
||||
}
|
||||
],
|
||||
"latest_import_time": "2025-07-22T12:50:00"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Nodes API
|
||||
## 1. Nodes API
|
||||
|
||||
### GET `/api/nodes`
|
||||
Returns a list of all nodes, with optional filtering by last seen.
|
||||
Returns a list of nodes, with optional filtering.
|
||||
|
||||
**Query Parameters**
|
||||
- `hours` (optional, int): Return nodes seen in the last N hours.
|
||||
- `days` (optional, int): Return nodes seen in the last N days.
|
||||
- `last_seen_after` (optional, ISO timestamp): Return nodes seen after this time.
|
||||
Query Parameters
|
||||
- `node_id` (optional, int): Exact node ID.
|
||||
- `role` (optional, string): Node role.
|
||||
- `channel` (optional, string): Channel name.
|
||||
- `hw_model` (optional, string): Hardware model.
|
||||
- `days_active` (optional, int): Nodes seen within the last N days.
|
||||
|
||||
**Response Example**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"nodes": [
|
||||
{
|
||||
"id": 42,
|
||||
"node_id": 1234,
|
||||
"long_name": "Alice",
|
||||
"short_name": "A",
|
||||
"channel": "main",
|
||||
"last_seen": "2025-07-22T12:40:00",
|
||||
"hardware": "T-Beam",
|
||||
"hw_model": "T-Beam",
|
||||
"firmware": "1.2.3",
|
||||
"role": "client",
|
||||
"last_lat": 37.7749,
|
||||
"last_long": -122.4194
|
||||
"last_lat": 377749000,
|
||||
"last_long": -1224194000,
|
||||
"channel": "main",
|
||||
"last_seen_us": 1736370123456789
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -84,45 +40,58 @@ Returns a list of all nodes, with optional filtering by last seen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Packets API
|
||||
## 2. Packets API
|
||||
|
||||
### GET `/api/packets`
|
||||
Returns a list of packets with optional filters.
|
||||
Returns packets with optional filters.
|
||||
|
||||
**Query Parameters**
|
||||
- `limit` (optional, int): Maximum number of packets to return. Default: `200`.
|
||||
- `since` (optional, ISO timestamp): Only packets imported after this timestamp are returned.
|
||||
Query Parameters
|
||||
- `packet_id` (optional, int): Return exactly one packet (overrides other filters).
|
||||
- `limit` (optional, int): Max packets to return, clamped 1-1000. Default: `50`.
|
||||
- `since` (optional, int): Only packets imported after this microsecond timestamp.
|
||||
- `portnum` (optional, int): Filter by port number.
|
||||
- `contains` (optional, string): Payload substring filter.
|
||||
- `from_node_id` (optional, int): Filter by sender node ID.
|
||||
- `to_node_id` (optional, int): Filter by recipient node ID.
|
||||
- `node_id` (optional, int): Legacy filter matching either from or to node ID.
|
||||
|
||||
**Response Example**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"packets": [
|
||||
{
|
||||
"id": 123,
|
||||
"import_time_us": 1736370123456789,
|
||||
"channel": "main",
|
||||
"from_node_id": 5678,
|
||||
"to_node_id": 91011,
|
||||
"portnum": 1,
|
||||
"import_time": "2025-07-22T12:45:00",
|
||||
"payload": "Hello, Bob!"
|
||||
"long_name": "Alice",
|
||||
"payload": "Hello, Bob!",
|
||||
"to_long_name": "Bob",
|
||||
"reply_id": 122
|
||||
}
|
||||
]
|
||||
],
|
||||
"latest_import_time": 1736370123456789
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
Notes
|
||||
- For `portnum=1` (text messages), packets are filtered to remove sequence-only payloads.
|
||||
- `latest_import_time` is returned when available for incremental polling (microseconds).
|
||||
|
||||
---
|
||||
|
||||
## 4. Channels API
|
||||
## 3. Channels API
|
||||
|
||||
### GET `/api/channels`
|
||||
Returns a list of channels seen in a given time period.
|
||||
Returns channels seen in a time period.
|
||||
|
||||
**Query Parameters**
|
||||
- `period_type` (optional, string): Time granularity (`hour` or `day`). Default: `hour`.
|
||||
Query Parameters
|
||||
- `period_type` (optional, string): `hour` or `day`. Default: `hour`.
|
||||
- `length` (optional, int): Number of periods to look back. Default: `24`.
|
||||
|
||||
**Response Example**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"channels": ["LongFast", "MediumFast", "ShortFast"]
|
||||
@@ -131,29 +100,21 @@ Returns a list of channels seen in a given time period.
|
||||
|
||||
---
|
||||
|
||||
## 5. Statistics API
|
||||
## 4. Stats API
|
||||
|
||||
### GET `/api/stats`
|
||||
Returns packet statistics aggregated by time periods, with optional filtering.
|
||||
|
||||
Retrieve packet statistics aggregated by time periods, with optional filtering.
|
||||
|
||||
---
|
||||
|
||||
## Query Parameters
|
||||
|
||||
| Parameter | Type | Required | Default | Description |
|
||||
|--------------|---------|----------|----------|-------------------------------------------------------------------------------------------------|
|
||||
| `period_type` | string | No | `hour` | Time granularity of the stats. Allowed values: `hour`, `day`. |
|
||||
| `length` | integer | No | 24 | Number of periods to include (hours or days). |
|
||||
| `channel` | string | No | — | Filter results by channel name (case-insensitive). |
|
||||
| `portnum` | integer | No | — | Filter results by port number. |
|
||||
| `to_node` | integer | No | — | Filter results to packets sent **to** this node ID. |
|
||||
| `from_node` | integer | No | — | Filter results to packets sent **from** this node ID. |
|
||||
|
||||
---
|
||||
|
||||
## Response
|
||||
Query Parameters
|
||||
- `period_type` (optional, string): `hour` or `day`. Default: `hour`.
|
||||
- `length` (optional, int): Number of periods to include. Default: `24`.
|
||||
- `channel` (optional, string): Filter by channel (case-insensitive).
|
||||
- `portnum` (optional, int): Filter by port number.
|
||||
- `to_node` (optional, int): Filter by destination node ID.
|
||||
- `from_node` (optional, int): Filter by source node ID.
|
||||
- `node` (optional, int): If provided, return combined `sent` and `seen` totals for that node.
|
||||
|
||||
Response Example (series)
|
||||
```json
|
||||
{
|
||||
"period_type": "hour",
|
||||
@@ -163,65 +124,117 @@ Retrieve packet statistics aggregated by time periods, with optional filtering.
|
||||
"to_node": 12345678,
|
||||
"from_node": 87654321,
|
||||
"data": [
|
||||
{ "period": "2025-08-08 14:00", "count": 10 },
|
||||
{ "period": "2025-08-08 15:00", "count": 7 }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Response Example (`node` totals)
|
||||
```json
|
||||
{
|
||||
"node_id": 12345678,
|
||||
"period_type": "hour",
|
||||
"length": 24,
|
||||
"sent": 42,
|
||||
"seen": 58
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/stats/count`
|
||||
Returns total packet counts, optionally filtered.
|
||||
|
||||
Query Parameters
|
||||
- `packet_id` (optional, int): Filter packet_seen by packet ID.
|
||||
- `period_type` (optional, string): `hour` or `day`.
|
||||
- `length` (optional, int): Number of periods to include.
|
||||
- `channel` (optional, string): Filter by channel.
|
||||
- `from_node` (optional, int): Filter by source node ID.
|
||||
- `to_node` (optional, int): Filter by destination node ID.
|
||||
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"total_packets": 12345,
|
||||
"total_seen": 67890
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET `/api/stats/top`
|
||||
Returns nodes sorted by packets seen, with pagination.
|
||||
|
||||
Query Parameters
|
||||
- `period_type` (optional, string): `hour` or `day`. Default: `day`.
|
||||
- `length` (optional, int): Number of periods to include. Default: `1`.
|
||||
- `channel` (optional, string): Filter by channel.
|
||||
- `limit` (optional, int): Max nodes to return. Default: `20`, max `100`.
|
||||
- `offset` (optional, int): Pagination offset. Default: `0`.
|
||||
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"total": 250,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"period": "2025-08-08 14:00",
|
||||
"count": 10
|
||||
},
|
||||
{
|
||||
"period": "2025-08-08 15:00",
|
||||
"count": 7
|
||||
"node_id": 1234,
|
||||
"long_name": "Alice",
|
||||
"short_name": "A",
|
||||
"channel": "main",
|
||||
"sent": 100,
|
||||
"seen": 240,
|
||||
"avg": 2.4
|
||||
}
|
||||
// more entries...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Edges API
|
||||
## 5. Edges API
|
||||
|
||||
### GET `/api/edges`
|
||||
Returns network edges (connections between nodes) based on traceroutes and neighbor info.
|
||||
Traceroute edges are collected over the last 12 hours. Neighbor edges are based on
|
||||
port 71 packets.
|
||||
|
||||
**Query Parameters**
|
||||
- `type` (optional, string): Filter by edge type (`traceroute` or `neighbor`). If omitted, returns both types.
|
||||
Query Parameters
|
||||
- `type` (optional, string): `traceroute` or `neighbor`. If omitted, returns both.
|
||||
- `node_id` (optional, int): Filter edges to only those touching a node.
|
||||
|
||||
**Response Example**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"from": 12345678,
|
||||
"to": 87654321,
|
||||
"type": "traceroute"
|
||||
},
|
||||
{
|
||||
"from": 11111111,
|
||||
"to": 22222222,
|
||||
"type": "neighbor"
|
||||
}
|
||||
{ "from": 12345678, "to": 87654321, "type": "traceroute" },
|
||||
{ "from": 11111111, "to": 22222222, "type": "neighbor" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration API
|
||||
## 6. Config API
|
||||
|
||||
### GET `/api/config`
|
||||
Returns the current site configuration (safe subset exposed to clients).
|
||||
Returns a safe subset of server configuration.
|
||||
|
||||
**Response Example**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"site": {
|
||||
"domain": "meshview.example.com",
|
||||
"domain": "example.com",
|
||||
"language": "en",
|
||||
"title": "Bay Area Mesh",
|
||||
"message": "Real time data from around the bay area",
|
||||
"title": "Meshview",
|
||||
"message": "",
|
||||
"starting": "/chat",
|
||||
"nodes": "true",
|
||||
"conversations": "true",
|
||||
"chat": "true",
|
||||
"everything": "true",
|
||||
"graphs": "true",
|
||||
"stats": "true",
|
||||
@@ -236,11 +249,11 @@ Returns the current site configuration (safe subset exposed to clients).
|
||||
"firehose_interval": 3,
|
||||
"weekly_net_message": "Weekly Mesh check-in message.",
|
||||
"net_tag": "#BayMeshNet",
|
||||
"version": "2.0.8 ~ 10-22-25"
|
||||
"version": "3.0.0"
|
||||
},
|
||||
"mqtt": {
|
||||
"server": "mqtt.bayme.sh",
|
||||
"topics": ["msh/US/bayarea/#"]
|
||||
"server": "mqtt.example.com",
|
||||
"topics": ["msh/region/#"]
|
||||
},
|
||||
"cleanup": {
|
||||
"enabled": "false",
|
||||
@@ -254,91 +267,126 @@ Returns the current site configuration (safe subset exposed to clients).
|
||||
|
||||
---
|
||||
|
||||
## 8. Language/Translations API
|
||||
## 7. Language API
|
||||
|
||||
### GET `/api/lang`
|
||||
Returns translation strings for the UI.
|
||||
Returns translation strings.
|
||||
|
||||
**Query Parameters**
|
||||
- `lang` (optional, string): Language code (e.g., `en`, `es`). Defaults to site language setting.
|
||||
- `section` (optional, string): Specific section to retrieve translations for.
|
||||
Query Parameters
|
||||
- `lang` (optional, string): Language code (e.g., `en`, `es`). Default from config or `en`.
|
||||
- `section` (optional, string): Return only one section (e.g., `nodelist`, `firehose`).
|
||||
|
||||
**Response Example (full)**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"chat": {
|
||||
"title": "Chat",
|
||||
"send": "Send"
|
||||
},
|
||||
"map": {
|
||||
"title": "Map",
|
||||
"zoom_in": "Zoom In"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example (section-specific)**
|
||||
Request: `/api/lang?section=chat`
|
||||
```json
|
||||
{
|
||||
"title": "Chat",
|
||||
"send": "Send"
|
||||
"title": "Meshview",
|
||||
"search_placeholder": "Search..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Health Check API
|
||||
## 8. Packets Seen API
|
||||
|
||||
### GET `/api/packets_seen/{packet_id}`
|
||||
Returns packet_seen entries for a packet.
|
||||
|
||||
Path Parameters
|
||||
- `packet_id` (required, int): Packet ID.
|
||||
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"seen": [
|
||||
{
|
||||
"packet_id": 123,
|
||||
"node_id": 456,
|
||||
"rx_time": "2025-07-22T12:45:00",
|
||||
"hop_limit": 7,
|
||||
"hop_start": 0,
|
||||
"channel": "main",
|
||||
"rx_snr": 5.0,
|
||||
"rx_rssi": -90,
|
||||
"topic": "msh/region/#",
|
||||
"import_time_us": 1736370123456789
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Traceroute API
|
||||
|
||||
### GET `/api/traceroute/{packet_id}`
|
||||
Returns traceroute details and derived paths for a packet.
|
||||
|
||||
Path Parameters
|
||||
- `packet_id` (required, int): Packet ID.
|
||||
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"packet": {
|
||||
"id": 123,
|
||||
"from": 111,
|
||||
"to": 222,
|
||||
"channel": "main"
|
||||
},
|
||||
"traceroute_packets": [
|
||||
{
|
||||
"index": 0,
|
||||
"gateway_node_id": 333,
|
||||
"done": true,
|
||||
"forward_hops": [111, 444, 222],
|
||||
"reverse_hops": [222, 444, 111]
|
||||
}
|
||||
],
|
||||
"unique_forward_paths": [
|
||||
{ "path": [111, 444, 222], "count": 2 }
|
||||
],
|
||||
"unique_reverse_paths": [
|
||||
[222, 444, 111]
|
||||
],
|
||||
"winning_paths": [
|
||||
[111, 444, 222]
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Health API
|
||||
|
||||
### GET `/health`
|
||||
Health check endpoint for monitoring, load balancers, and orchestration systems.
|
||||
Returns service health and database status.
|
||||
|
||||
**Response Example (Healthy)**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2025-11-03T14:30:00.123456Z",
|
||||
"version": "3.0.0",
|
||||
"git_revision": "6416978",
|
||||
"timestamp": "2025-07-22T12:45:00+00:00",
|
||||
"version": "3.0.3",
|
||||
"git_revision": "abc1234",
|
||||
"database": "connected",
|
||||
"database_size": "853.03 MB",
|
||||
"database_size_bytes": 894468096
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example (Unhealthy)**
|
||||
Status Code: `503 Service Unavailable`
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"timestamp": "2025-11-03T14:30:00.123456Z",
|
||||
"version": "2.0.8",
|
||||
"git_revision": "6416978",
|
||||
"database": "disconnected"
|
||||
"database_size": "12.34 MB",
|
||||
"database_size_bytes": 12939444
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Version API
|
||||
## 11. Version API
|
||||
|
||||
### GET `/version`
|
||||
Returns detailed version information including semver, release date, and git revision.
|
||||
Returns version metadata.
|
||||
|
||||
**Response Example**
|
||||
Response Example
|
||||
```json
|
||||
{
|
||||
"version": "2.0.8",
|
||||
"release_date": "2025-10-22",
|
||||
"git_revision": "6416978a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q",
|
||||
"git_revision_short": "6416978"
|
||||
"version": "3.0.3",
|
||||
"release_date": "2026-1-15",
|
||||
"git_revision": "abc1234",
|
||||
"git_revision_short": "abc1234"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- All timestamps (`import_time`, `last_seen`) are returned in ISO 8601 format.
|
||||
- `portnum` is an integer representing the packet type.
|
||||
- `payload` is always a UTF-8 decoded string.
|
||||
- Node IDs are integers (e.g., `12345678`).
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "3.0.1"
|
||||
__release_date__ = "2025-12-4"
|
||||
__version__ = "3.0.4"
|
||||
__release_date__ = "2026-1-24"
|
||||
|
||||
|
||||
def get_git_revision():
|
||||
|
||||
@@ -6,7 +6,7 @@ parser = argparse.ArgumentParser(description="MeshView Configuration Loader")
|
||||
parser.add_argument(
|
||||
"--config", type=str, default="config.ini", help="Path to config.ini file (default: config.ini)"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
args, _ = parser.parse_known_args()
|
||||
|
||||
# Initialize config parser
|
||||
config_parser = configparser.ConfigParser()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from meshview import models
|
||||
@@ -9,10 +10,19 @@ async_session = None
|
||||
def init_database(database_connection_string):
|
||||
global engine, async_session
|
||||
kwargs = {"echo": False}
|
||||
# Ensure SQLite is opened in read-only mode
|
||||
database_connection_string += "?mode=ro"
|
||||
kwargs["connect_args"] = {"uri": True}
|
||||
engine = create_async_engine(database_connection_string, **kwargs)
|
||||
url = make_url(database_connection_string)
|
||||
connect_args = {}
|
||||
|
||||
if url.drivername.startswith("sqlite"):
|
||||
query = dict(url.query)
|
||||
query.setdefault("mode", "ro")
|
||||
url = url.set(query=query)
|
||||
connect_args["uri"] = True
|
||||
|
||||
if connect_args:
|
||||
kwargs["connect_args"] = connect_args
|
||||
|
||||
engine = create_async_engine(url, **kwargs)
|
||||
async_session = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
|
||||
@@ -179,7 +179,11 @@
|
||||
"to": "To",
|
||||
"port": "Port",
|
||||
"direct_to_mqtt": "Direct to MQTT",
|
||||
"all_broadcast": "All"
|
||||
"all_broadcast": "All",
|
||||
"statistics": "Statistics",
|
||||
"last_24h": "24h",
|
||||
"packets_sent": "Packets sent",
|
||||
"times_seen": "Times seen"
|
||||
},
|
||||
"packet": {
|
||||
"loading": "Loading packet information...",
|
||||
|
||||
@@ -164,7 +164,11 @@
|
||||
"to": "A",
|
||||
"port": "Puerto",
|
||||
"direct_to_mqtt": "Directo a MQTT",
|
||||
"all_broadcast": "Todos"
|
||||
"all_broadcast": "Todos",
|
||||
"statistics": "Estadísticas",
|
||||
"last_24h": "24h",
|
||||
"packets_sent": "Paquetes enviados",
|
||||
"times_seen": "Veces visto"
|
||||
},
|
||||
|
||||
"packet": {
|
||||
|
||||
@@ -186,19 +186,24 @@ async def create_migration_status_table(engine: AsyncEngine) -> None:
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS migration_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
in_progress BOOLEAN NOT NULL DEFAULT 0,
|
||||
in_progress BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
# Insert initial row if not exists
|
||||
await conn.execute(
|
||||
result = await conn.execute(
|
||||
text("""
|
||||
INSERT OR IGNORE INTO migration_status (id, in_progress)
|
||||
VALUES (1, 0)
|
||||
SELECT 1 FROM migration_status WHERE id = 1
|
||||
""")
|
||||
)
|
||||
if result.first() is None:
|
||||
await conn.execute(
|
||||
text("""
|
||||
INSERT INTO migration_status (id, in_progress)
|
||||
VALUES (1, FALSE)
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
async def set_migration_in_progress(engine: AsyncEngine, in_progress: bool) -> None:
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import BigInteger, ForeignKey, Index, desc
|
||||
from sqlalchemy.ext.asyncio import AsyncAttrs
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
@@ -22,7 +20,6 @@ class Node(Base):
|
||||
last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
last_long: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
channel: Mapped[str] = mapped_column(nullable=True)
|
||||
last_update: Mapped[datetime] = mapped_column(nullable=True)
|
||||
first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
@@ -33,11 +30,7 @@ class Node(Base):
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
column.name: getattr(self, column.name)
|
||||
for column in self.__table__.columns
|
||||
if column.name != "last_update"
|
||||
}
|
||||
return {column.name: getattr(self, column.name) for column in self.__table__.columns}
|
||||
|
||||
|
||||
class Packet(Base):
|
||||
@@ -55,17 +48,13 @@ class Packet(Base):
|
||||
overlaps="from_node",
|
||||
)
|
||||
payload: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
channel: Mapped[str] = mapped_column(nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_packet_from_node_id", "from_node_id"),
|
||||
Index("idx_packet_to_node_id", "to_node_id"),
|
||||
Index("idx_packet_import_time", desc("import_time")),
|
||||
Index("idx_packet_import_time_us", desc("import_time_us")),
|
||||
# Composite index for /top endpoint performance - filters by from_node_id AND import_time
|
||||
Index("idx_packet_from_node_time", "from_node_id", desc("import_time")),
|
||||
Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
|
||||
)
|
||||
|
||||
@@ -86,7 +75,6 @@ class PacketSeen(Base):
|
||||
rx_snr: Mapped[float] = mapped_column(nullable=True)
|
||||
rx_rssi: Mapped[int] = mapped_column(nullable=True)
|
||||
topic: Mapped[str] = mapped_column(nullable=True)
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
@@ -108,11 +96,11 @@ class Traceroute(Base):
|
||||
gateway_node_id: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
done: Mapped[bool] = mapped_column(nullable=True)
|
||||
route: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
route_return: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_traceroute_import_time", "import_time"),
|
||||
Index("idx_traceroute_import_time_us", "import_time_us"),
|
||||
)
|
||||
Index("idx_traceroute_packet_id", "packet_id"),
|
||||
Index("idx_traceroute_import_time_us", "import_time_us"),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,13 +1,32 @@
|
||||
from sqlalchemy.engine.url import make_url
|
||||
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
||||
from sqlalchemy import event
|
||||
|
||||
from meshview import models
|
||||
|
||||
|
||||
def init_database(database_connection_string):
|
||||
global engine, async_session
|
||||
engine = create_async_engine(
|
||||
database_connection_string, echo=False, connect_args={"timeout": 900}
|
||||
)
|
||||
|
||||
url = make_url(database_connection_string)
|
||||
kwargs = {"echo": False}
|
||||
|
||||
if url.drivername.startswith("sqlite"):
|
||||
kwargs["connect_args"] = {"timeout": 900} # seconds
|
||||
|
||||
engine = create_async_engine(url, **kwargs)
|
||||
|
||||
# Enforce SQLite pragmas on every new DB connection
|
||||
if url.drivername.startswith("sqlite"):
|
||||
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragmas(dbapi_conn, _):
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL;")
|
||||
cursor.execute("PRAGMA busy_timeout=900000;") # ms
|
||||
cursor.execute("PRAGMA synchronous=NORMAL;")
|
||||
cursor.close()
|
||||
|
||||
async_session = async_sessionmaker(engine, expire_on_commit=False)
|
||||
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from google.protobuf.message import DecodeError
|
||||
|
||||
from meshtastic.protobuf.mqtt_pb2 import ServiceEnvelope
|
||||
from meshview.config import CONFIG
|
||||
|
||||
KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
|
||||
PRIMARY_KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
@@ -21,20 +22,79 @@ logging.basicConfig(
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def decrypt(packet):
|
||||
if packet.HasField("decoded"):
|
||||
return
|
||||
def _parse_skip_node_ids():
|
||||
mqtt_config = CONFIG.get("mqtt", {})
|
||||
raw_value = mqtt_config.get("skip_node_ids", "")
|
||||
if not raw_value:
|
||||
return set()
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if not raw_value:
|
||||
return set()
|
||||
values = [v.strip() for v in raw_value.split(",") if v.strip()]
|
||||
else:
|
||||
values = [raw_value]
|
||||
|
||||
skip_ids = set()
|
||||
for value in values:
|
||||
try:
|
||||
skip_ids.add(int(value, 0))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid node id in mqtt.skip_node_ids: %s", value)
|
||||
return skip_ids
|
||||
|
||||
|
||||
def _parse_secondary_keys():
|
||||
mqtt_config = CONFIG.get("mqtt", {})
|
||||
raw_value = mqtt_config.get("secondary_keys", "")
|
||||
if not raw_value:
|
||||
return []
|
||||
|
||||
if isinstance(raw_value, str):
|
||||
raw_value = raw_value.strip()
|
||||
if not raw_value:
|
||||
return []
|
||||
values = [v.strip() for v in raw_value.split(",") if v.strip()]
|
||||
else:
|
||||
values = [raw_value]
|
||||
|
||||
keys = []
|
||||
for value in values:
|
||||
try:
|
||||
keys.append(base64.b64decode(value))
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Invalid base64 key in mqtt.secondary_keys: %s", value)
|
||||
return keys
|
||||
|
||||
|
||||
SKIP_NODE_IDS = _parse_skip_node_ids()
|
||||
SECONDARY_KEYS = _parse_secondary_keys()
|
||||
|
||||
|
||||
def _try_decrypt(packet, key):
|
||||
packet_id = packet.id.to_bytes(8, "little")
|
||||
from_node_id = getattr(packet, "from").to_bytes(8, "little")
|
||||
nonce = packet_id + from_node_id
|
||||
|
||||
cipher = Cipher(algorithms.AES(KEY), modes.CTR(nonce))
|
||||
cipher = Cipher(algorithms.AES(key), modes.CTR(nonce))
|
||||
decryptor = cipher.decryptor()
|
||||
raw_proto = decryptor.update(packet.encrypted) + decryptor.finalize()
|
||||
try:
|
||||
packet.decoded.ParseFromString(raw_proto)
|
||||
except DecodeError:
|
||||
pass
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def decrypt(packet):
|
||||
if packet.HasField("decoded"):
|
||||
return
|
||||
if _try_decrypt(packet, PRIMARY_KEY):
|
||||
return
|
||||
for key in SECONDARY_KEYS:
|
||||
if _try_decrypt(packet, key):
|
||||
return
|
||||
|
||||
|
||||
async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd):
|
||||
@@ -70,9 +130,8 @@ async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_pa
|
||||
if not envelope.packet.decoded:
|
||||
continue
|
||||
|
||||
# Skip packets from specific node
|
||||
# FIXME: make this configurable as a list of node IDs to skip
|
||||
if getattr(envelope.packet, "from", None) == 2144342101:
|
||||
# Skip packets from configured node IDs
|
||||
if getattr(envelope.packet, "from", None) in SKIP_NODE_IDS:
|
||||
continue
|
||||
|
||||
msg_count += 1
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import datetime
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||||
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from meshtastic.protobuf.config_pb2 import Config
|
||||
from meshtastic.protobuf.mesh_pb2 import HardwareModel
|
||||
@@ -10,6 +14,8 @@ from meshtastic.protobuf.portnums_pb2 import PortNum
|
||||
from meshview import decode_payload, mqtt_database
|
||||
from meshview.models import Node, Packet, PacketSeen, Traceroute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def process_envelope(topic, env):
|
||||
# MAP_REPORT_APP
|
||||
@@ -37,8 +43,7 @@ async def process_envelope(topic, env):
|
||||
await session.execute(select(Node).where(Node.node_id == node_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
|
||||
if node:
|
||||
node.node_id = node_id
|
||||
@@ -50,7 +55,6 @@ async def process_envelope(topic, env):
|
||||
node.last_lat = map_report.latitude_i
|
||||
node.last_long = map_report.longitude_i
|
||||
node.firmware = map_report.firmware_version
|
||||
node.last_update = now
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
node.first_seen_us = now_us
|
||||
@@ -66,7 +70,6 @@ async def process_envelope(topic, env):
|
||||
firmware=map_report.firmware_version,
|
||||
last_lat=map_report.latitude_i,
|
||||
last_long=map_report.longitude_i,
|
||||
last_update=now,
|
||||
first_seen_us=now_us,
|
||||
last_seen_us=now_us,
|
||||
)
|
||||
@@ -82,29 +85,43 @@ async def process_envelope(topic, env):
|
||||
async with mqtt_database.async_session() as session:
|
||||
# --- Packet insert with ON CONFLICT DO NOTHING
|
||||
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
|
||||
# FIXME: Not Used
|
||||
# new_packet = False
|
||||
packet = result.scalar_one_or_none()
|
||||
if not packet:
|
||||
# FIXME: Not Used
|
||||
# new_packet = True
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
stmt = (
|
||||
sqlite_insert(Packet)
|
||||
.values(
|
||||
id=env.packet.id,
|
||||
portnum=env.packet.decoded.portnum,
|
||||
from_node_id=getattr(env.packet, "from"),
|
||||
to_node_id=env.packet.to,
|
||||
payload=env.packet.SerializeToString(),
|
||||
import_time=now,
|
||||
import_time_us=now_us,
|
||||
channel=env.channel_id,
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
packet_values = {
|
||||
"id": env.packet.id,
|
||||
"portnum": env.packet.decoded.portnum,
|
||||
"from_node_id": getattr(env.packet, "from"),
|
||||
"to_node_id": env.packet.to,
|
||||
"payload": env.packet.SerializeToString(),
|
||||
"import_time_us": now_us,
|
||||
"channel": env.channel_id,
|
||||
}
|
||||
utc_time = datetime.datetime.fromtimestamp(now_us / 1_000_000, datetime.UTC)
|
||||
dialect = session.get_bind().dialect.name
|
||||
stmt = None
|
||||
if dialect == "sqlite":
|
||||
stmt = (
|
||||
sqlite_insert(Packet)
|
||||
.values(**packet_values)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
)
|
||||
await session.execute(stmt)
|
||||
elif dialect == "postgresql":
|
||||
stmt = (
|
||||
pg_insert(Packet)
|
||||
.values(**packet_values)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
)
|
||||
|
||||
if stmt is not None:
|
||||
await session.execute(stmt)
|
||||
else:
|
||||
try:
|
||||
async with session.begin_nested():
|
||||
session.add(Packet(**packet_values))
|
||||
await session.flush()
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
# --- PacketSeen (no conflict handling here, normal insert)
|
||||
|
||||
@@ -123,8 +140,7 @@ async def process_envelope(topic, env):
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
seen = PacketSeen(
|
||||
packet_id=env.packet.id,
|
||||
node_id=int(env.gateway_id[1:], 16),
|
||||
@@ -135,7 +151,6 @@ async def process_envelope(topic, env):
|
||||
hop_limit=env.packet.hop_limit,
|
||||
hop_start=env.packet.hop_start,
|
||||
topic=topic,
|
||||
import_time=now,
|
||||
import_time_us=now_us,
|
||||
)
|
||||
session.add(seen)
|
||||
@@ -167,8 +182,7 @@ async def process_envelope(topic, env):
|
||||
await session.execute(select(Node).where(Node.id == user.id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
|
||||
if node:
|
||||
node.node_id = node_id
|
||||
@@ -177,7 +191,6 @@ async def process_envelope(topic, env):
|
||||
node.hw_model = hw_model
|
||||
node.role = role
|
||||
node.channel = env.channel_id
|
||||
node.last_update = now
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
node.first_seen_us = now_us
|
||||
@@ -190,7 +203,6 @@ async def process_envelope(topic, env):
|
||||
hw_model=hw_model,
|
||||
role=role,
|
||||
channel=env.channel_id,
|
||||
last_update=now,
|
||||
first_seen_us=now_us,
|
||||
last_seen_us=now_us,
|
||||
)
|
||||
@@ -209,11 +221,9 @@ async def process_envelope(topic, env):
|
||||
await session.execute(select(Node).where(Node.node_id == from_node_id))
|
||||
).scalar_one_or_none()
|
||||
if node:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
node.last_lat = position.latitude_i
|
||||
node.last_long = position.longitude_i
|
||||
node.last_update = now
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
node.first_seen_us = now_us
|
||||
@@ -223,21 +233,15 @@ async def process_envelope(topic, env):
|
||||
if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP:
|
||||
packet_id = env.packet.id
|
||||
if packet_id is not None:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
now_us = int(time.time() * 1_000_000)
|
||||
session.add(
|
||||
Traceroute(
|
||||
packet_id=packet_id,
|
||||
route=env.packet.decoded.payload,
|
||||
done=not env.packet.decoded.want_response,
|
||||
gateway_node_id=int(env.gateway_id[1:], 16),
|
||||
import_time=now,
|
||||
import_time_us=now_us,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# if new_packet:
|
||||
# await packet.awaitable_attrs.to_node
|
||||
# await packet.awaitable_attrs.from_node
|
||||
|
||||
@@ -75,8 +75,8 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
return color;
|
||||
}
|
||||
|
||||
function timeAgo(dateStr){
|
||||
const diff = Date.now() - new Date(dateStr);
|
||||
function timeAgoFromUs(us){
|
||||
const diff = Date.now() - (us / 1000);
|
||||
const s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24);
|
||||
if(d>0) return d+'d'; if(h>0) return h+'h'; if(m>0) return m+'m'; return s+'s';
|
||||
}
|
||||
@@ -118,7 +118,7 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
<b>Channel:</b> ${node.channel}<br>
|
||||
<b>Model:</b> ${node.hw_model}<br>
|
||||
<b>Role:</b> ${node.role}<br>`;
|
||||
if(node.last_update) popupContent+=`<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`;
|
||||
if(node.last_seen_us) popupContent+=`<b>Last seen:</b> ${timeAgoFromUs(node.last_seen_us)}<br>`;
|
||||
if(node.firmware) popupContent+=`<b>Firmware:</b> ${node.firmware}<br>`;
|
||||
|
||||
marker.on('click', e=>{
|
||||
|
||||
36
meshview/static/portmaps.js
Normal file
36
meshview/static/portmaps.js
Normal file
@@ -0,0 +1,36 @@
|
||||
// Shared port label/color definitions for UI pages.
|
||||
window.PORT_LABEL_MAP = {
|
||||
0: "UNKNOWN",
|
||||
1: "Text",
|
||||
3: "Position",
|
||||
4: "Node Info",
|
||||
5: "Routing",
|
||||
6: "Admin",
|
||||
8: "Waypoint",
|
||||
35: "Store Forward++",
|
||||
65: "Store & Forward",
|
||||
67: "Telemetry",
|
||||
70: "Traceroute",
|
||||
71: "Neighbor",
|
||||
73: "Map Report",
|
||||
};
|
||||
|
||||
window.PORT_COLOR_MAP = {
|
||||
0: "#6c757d",
|
||||
1: "#007bff",
|
||||
3: "#28a745",
|
||||
4: "#ffc107",
|
||||
5: "#dc3545",
|
||||
6: "#20c997",
|
||||
8: "#fd7e14",
|
||||
35: "#8bc34a",
|
||||
65: "#6610f2",
|
||||
67: "#17a2b8",
|
||||
70: "#ff4444",
|
||||
71: "#ff66cc",
|
||||
73: "#9999ff",
|
||||
};
|
||||
|
||||
// Aliases for pages that expect different names.
|
||||
window.PORT_MAP = window.PORT_LABEL_MAP;
|
||||
window.PORT_COLORS = window.PORT_COLOR_MAP;
|
||||
@@ -1,10 +1,14 @@
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import select, and_, or_, func, cast, Text
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import Text, and_, cast, func, or_, select
|
||||
from sqlalchemy.orm import lazyload
|
||||
|
||||
from meshview import database, models
|
||||
from meshview.models import Node, Packet, PacketSeen, Traceroute
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_node(node_id):
|
||||
async with database.async_session() as session:
|
||||
@@ -91,8 +95,10 @@ async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
|
||||
if portnum:
|
||||
q = q.where(Packet.portnum == portnum)
|
||||
if since:
|
||||
q = q.where(Packet.import_time > (datetime.now() - since))
|
||||
result = await session.execute(q.limit(limit).order_by(Packet.import_time.desc()))
|
||||
now_us = int(datetime.now().timestamp() * 1_000_000)
|
||||
start_us = now_us - int(since.total_seconds() * 1_000_000)
|
||||
q = q.where(Packet.import_time_us > start_us)
|
||||
result = await session.execute(q.limit(limit).order_by(Packet.import_time_us.desc()))
|
||||
return result.scalars()
|
||||
|
||||
|
||||
@@ -108,7 +114,7 @@ async def get_packets_seen(packet_id):
|
||||
result = await session.execute(
|
||||
select(PacketSeen)
|
||||
.where(PacketSeen.packet_id == packet_id)
|
||||
.order_by(PacketSeen.import_time.desc())
|
||||
.order_by(PacketSeen.import_time_us.desc())
|
||||
)
|
||||
return result.scalars()
|
||||
|
||||
@@ -129,18 +135,21 @@ async def get_traceroute(packet_id):
|
||||
result = await session.execute(
|
||||
select(Traceroute)
|
||||
.where(Traceroute.packet_id == packet_id)
|
||||
.order_by(Traceroute.import_time)
|
||||
.order_by(Traceroute.import_time_us)
|
||||
)
|
||||
return result.scalars()
|
||||
|
||||
|
||||
async def get_traceroutes(since):
|
||||
if isinstance(since, datetime):
|
||||
since_us = int(since.timestamp() * 1_000_000)
|
||||
else:
|
||||
since_us = int(since)
|
||||
async with database.async_session() as session:
|
||||
stmt = (
|
||||
select(Traceroute)
|
||||
.join(Packet)
|
||||
.where(Traceroute.import_time > since)
|
||||
.order_by(Traceroute.import_time)
|
||||
.where(Traceroute.import_time_us > since_us)
|
||||
.order_by(Traceroute.import_time_us)
|
||||
)
|
||||
stream = await session.stream_scalars(stmt)
|
||||
async for tr in stream:
|
||||
@@ -148,6 +157,8 @@ async def get_traceroutes(since):
|
||||
|
||||
|
||||
async def get_mqtt_neighbors(since):
|
||||
now_us = int(datetime.now().timestamp() * 1_000_000)
|
||||
start_us = now_us - int(since.total_seconds() * 1_000_000)
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
select(PacketSeen, Packet)
|
||||
@@ -155,7 +166,7 @@ async def get_mqtt_neighbors(since):
|
||||
.where(
|
||||
(PacketSeen.hop_limit == PacketSeen.hop_start)
|
||||
& (PacketSeen.hop_start != 0)
|
||||
& (PacketSeen.import_time > (datetime.now() - since))
|
||||
& (PacketSeen.import_time_us > start_us)
|
||||
)
|
||||
.options(
|
||||
lazyload(Packet.from_node),
|
||||
@@ -168,9 +179,9 @@ async def get_mqtt_neighbors(since):
|
||||
async def get_total_node_count(channel: str = None) -> int:
|
||||
try:
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(Node.id)).where(
|
||||
Node.last_update > datetime.now() - timedelta(days=1)
|
||||
)
|
||||
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
|
||||
cutoff_us = now_us - 86400 * 1_000_000
|
||||
q = select(func.count(Node.id)).where(Node.last_seen_us > cutoff_us)
|
||||
|
||||
if channel:
|
||||
q = q.where(Node.channel == channel)
|
||||
@@ -185,26 +196,32 @@ async def get_total_node_count(channel: str = None) -> int:
|
||||
async def get_top_traffic_nodes():
|
||||
try:
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT
|
||||
n.node_id,
|
||||
n.long_name,
|
||||
n.short_name,
|
||||
n.channel,
|
||||
COUNT(DISTINCT p.id) AS total_packets_sent,
|
||||
COUNT(ps.packet_id) AS total_times_seen
|
||||
FROM node n
|
||||
LEFT JOIN packet p ON n.node_id = p.from_node_id
|
||||
AND p.import_time >= DATETIME('now', 'localtime', '-24 hours')
|
||||
LEFT JOIN packet_seen ps ON p.id = ps.packet_id
|
||||
GROUP BY n.node_id, n.long_name, n.short_name
|
||||
HAVING total_packets_sent > 0
|
||||
ORDER BY total_times_seen DESC;
|
||||
""")
|
||||
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
|
||||
cutoff_us = now_us - 86400 * 1_000_000
|
||||
total_packets_sent = func.count(func.distinct(Packet.id)).label("total_packets_sent")
|
||||
total_times_seen = func.count(PacketSeen.packet_id).label("total_times_seen")
|
||||
|
||||
stmt = (
|
||||
select(
|
||||
Node.node_id,
|
||||
Node.long_name,
|
||||
Node.short_name,
|
||||
Node.channel,
|
||||
total_packets_sent,
|
||||
total_times_seen,
|
||||
)
|
||||
.select_from(Node)
|
||||
.outerjoin(
|
||||
Packet,
|
||||
(Packet.from_node_id == Node.node_id) & (Packet.import_time_us >= cutoff_us),
|
||||
)
|
||||
.outerjoin(PacketSeen, PacketSeen.packet_id == Packet.id)
|
||||
.group_by(Node.node_id, Node.long_name, Node.short_name, Node.channel)
|
||||
.having(total_packets_sent > 0)
|
||||
.order_by(total_times_seen.desc())
|
||||
)
|
||||
|
||||
rows = result.fetchall()
|
||||
rows = (await session.execute(stmt)).all()
|
||||
|
||||
nodes = [
|
||||
{
|
||||
@@ -227,33 +244,30 @@ async def get_top_traffic_nodes():
|
||||
async def get_node_traffic(node_id: int):
|
||||
try:
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT
|
||||
node.long_name, packet.portnum,
|
||||
COUNT(*) AS packet_count
|
||||
FROM packet
|
||||
JOIN node ON packet.from_node_id = node.node_id
|
||||
WHERE node.node_id = :node_id
|
||||
AND packet.import_time >= DATETIME('now', 'localtime', '-24 hours')
|
||||
GROUP BY packet.portnum
|
||||
ORDER BY packet_count DESC;
|
||||
"""),
|
||||
{"node_id": node_id},
|
||||
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
|
||||
cutoff_us = now_us - 86400 * 1_000_000
|
||||
packet_count = func.count().label("packet_count")
|
||||
|
||||
stmt = (
|
||||
select(Node.long_name, Packet.portnum, packet_count)
|
||||
.select_from(Packet)
|
||||
.join(Node, Packet.from_node_id == Node.node_id)
|
||||
.where(Node.node_id == node_id)
|
||||
.where(Packet.import_time_us >= cutoff_us)
|
||||
.group_by(Node.long_name, Packet.portnum)
|
||||
.order_by(packet_count.desc())
|
||||
)
|
||||
|
||||
# Map the result to include node.long_name and packet data
|
||||
traffic_data = [
|
||||
result = await session.execute(stmt)
|
||||
return [
|
||||
{
|
||||
"long_name": row[0], # node.long_name
|
||||
"portnum": row[1], # packet.portnum
|
||||
"packet_count": row[2], # COUNT(*) as packet_count
|
||||
"long_name": row.long_name,
|
||||
"portnum": row.portnum,
|
||||
"packet_count": row.packet_count,
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
|
||||
return traffic_data
|
||||
|
||||
except Exception as e:
|
||||
# Log the error or handle it as needed
|
||||
print(f"Error fetching node traffic: {str(e)}")
|
||||
@@ -282,7 +296,11 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
|
||||
|
||||
# Apply filters based on provided parameters
|
||||
if node_id is not None:
|
||||
query = query.where(Node.node_id == node_id)
|
||||
try:
|
||||
node_id_int = int(node_id)
|
||||
except (TypeError, ValueError):
|
||||
node_id_int = node_id
|
||||
query = query.where(Node.node_id == node_id_int)
|
||||
if role is not None:
|
||||
query = query.where(Node.role == role.upper()) # Ensure role is uppercase
|
||||
if channel is not None:
|
||||
@@ -291,10 +309,12 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
|
||||
query = query.where(Node.hw_model == hw_model)
|
||||
|
||||
if days_active is not None:
|
||||
query = query.where(Node.last_update > datetime.now() - timedelta(days_active))
|
||||
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000)
|
||||
cutoff_us = now_us - int(timedelta(days_active).total_seconds() * 1_000_000)
|
||||
query = query.where(Node.last_seen_us > cutoff_us)
|
||||
|
||||
# Exclude nodes where last_update is an empty string
|
||||
query = query.where(Node.last_update != "")
|
||||
# Exclude nodes with missing last_seen_us
|
||||
query = query.where(Node.last_seen_us.is_not(None))
|
||||
|
||||
# Order results by long_name in ascending order
|
||||
query = query.order_by(Node.short_name.asc())
|
||||
@@ -305,7 +325,7 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
|
||||
return nodes # Return the list of nodes
|
||||
|
||||
except Exception:
|
||||
print("error reading DB") # Consider using logging instead of print
|
||||
logger.exception("error reading DB")
|
||||
return [] # Return an empty list in case of failure
|
||||
|
||||
|
||||
@@ -317,22 +337,36 @@ async def get_packet_stats(
|
||||
to_node: int | None = None,
|
||||
from_node: int | None = None,
|
||||
):
|
||||
now = datetime.now()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if period_type == "hour":
|
||||
start_time = now - timedelta(hours=length)
|
||||
time_format = '%Y-%m-%d %H:00'
|
||||
time_format_sqlite = "%Y-%m-%d %H:00"
|
||||
time_format_pg = "YYYY-MM-DD HH24:00"
|
||||
elif period_type == "day":
|
||||
start_time = now - timedelta(days=length)
|
||||
time_format = '%Y-%m-%d'
|
||||
time_format_sqlite = "%Y-%m-%d"
|
||||
time_format_pg = "YYYY-MM-DD"
|
||||
else:
|
||||
raise ValueError("period_type must be 'hour' or 'day'")
|
||||
|
||||
async with database.async_session() as session:
|
||||
dialect = session.get_bind().dialect.name
|
||||
if dialect == "postgresql":
|
||||
period_expr = func.to_char(
|
||||
func.to_timestamp(Packet.import_time_us / 1_000_000.0),
|
||||
time_format_pg,
|
||||
)
|
||||
else:
|
||||
period_expr = func.strftime(
|
||||
time_format_sqlite,
|
||||
func.datetime(Packet.import_time_us / 1_000_000, "unixepoch"),
|
||||
)
|
||||
|
||||
q = select(
|
||||
func.strftime(time_format, Packet.import_time).label('period'),
|
||||
func.count().label('count'),
|
||||
).where(Packet.import_time >= start_time)
|
||||
period_expr.label("period"),
|
||||
func.count().label("count"),
|
||||
).where(Packet.import_time_us >= int(start_time.timestamp() * 1_000_000))
|
||||
|
||||
# Filters
|
||||
if channel:
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<script src="/static/portmaps.js"></script>
|
||||
<script>
|
||||
/* ======================================================
|
||||
FIREHOSE TRANSLATION SYSTEM (isolated from base)
|
||||
@@ -177,41 +178,8 @@ function nodeName(id) {
|
||||
/* ======================================================
|
||||
PORT COLORS & NAMES
|
||||
====================================================== */
|
||||
const PORT_MAP = {
|
||||
0: "UNKNOWN APP",
|
||||
1: "Text Message",
|
||||
3: "Position",
|
||||
4: "Node Info",
|
||||
5: "Routing",
|
||||
6: "Administration",
|
||||
8: "Waypoint",
|
||||
65: "Store Forward",
|
||||
67: "Telemetry",
|
||||
70: "Trace Route",
|
||||
71: "Neighbor Info"
|
||||
};
|
||||
|
||||
const PORT_COLORS = {
|
||||
0: "#6c757d",
|
||||
1: "#007bff",
|
||||
3: "#28a745",
|
||||
4: "#ffc107",
|
||||
5: "#dc3545",
|
||||
6: "#20c997",
|
||||
65: "#6610f2",
|
||||
67: "#17a2b8",
|
||||
68: "#fd7e14",
|
||||
69: "#6f42c1",
|
||||
70: "#ff4444",
|
||||
71: "#ff66cc",
|
||||
72: "#00cc99",
|
||||
73: "#9999ff",
|
||||
74: "#cc00cc",
|
||||
75: "#ffbb33",
|
||||
76: "#00bcd4",
|
||||
77: "#8bc34a",
|
||||
78: "#795548"
|
||||
};
|
||||
const PORT_MAP = window.PORT_MAP || {};
|
||||
const PORT_COLORS = window.PORT_COLORS || {};
|
||||
|
||||
function portLabel(portnum, payload, linksHtml) {
|
||||
const name = PORT_MAP[portnum] || "Unknown";
|
||||
@@ -233,13 +201,37 @@ function portLabel(portnum, payload, linksHtml) {
|
||||
/* ======================================================
|
||||
TIME FORMAT
|
||||
====================================================== */
|
||||
function formatLocalTime(importTimeUs) {
|
||||
const ms = importTimeUs / 1000;
|
||||
return new Date(ms).toLocaleTimeString([], {
|
||||
function formatTimes(importTimeUs) {
|
||||
const ms = Number(importTimeUs) / 1000;
|
||||
if (!Number.isFinite(ms)) {
|
||||
return { local: "—", utc: "—", epoch: "—" };
|
||||
}
|
||||
const date = new Date(ms);
|
||||
const local = date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
second: "2-digit",
|
||||
timeZoneName: "short"
|
||||
});
|
||||
const utc = date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
timeZone: "UTC",
|
||||
timeZoneName: "short"
|
||||
});
|
||||
return { local, utc, epoch: String(importTimeUs) };
|
||||
}
|
||||
|
||||
function logPacketTimes(packet) {
|
||||
const times = formatTimes(packet.import_time_us);
|
||||
console.log(
|
||||
"[firehose] packet time",
|
||||
"id=" + packet.id,
|
||||
"epoch_us=" + times.epoch,
|
||||
"local=" + times.local,
|
||||
"utc=" + times.utc
|
||||
);
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
@@ -261,7 +253,7 @@ async function fetchUpdates() {
|
||||
if (updatesPaused) return;
|
||||
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
url.searchParams.set("limit", 50);
|
||||
url.searchParams.set("limit", 100);
|
||||
|
||||
if (lastImportTimeUs)
|
||||
url.searchParams.set("since", lastImportTimeUs);
|
||||
@@ -277,6 +269,7 @@ async function fetchUpdates() {
|
||||
const list = document.getElementById("packet_list");
|
||||
|
||||
for (const pkt of packets.reverse()) {
|
||||
logPacketTimes(pkt);
|
||||
|
||||
/* FROM — includes translation */
|
||||
const from =
|
||||
@@ -336,7 +329,9 @@ async function fetchUpdates() {
|
||||
const html = `
|
||||
<tr class="packet-row">
|
||||
|
||||
<td>${formatLocalTime(pkt.import_time_us)}</td>
|
||||
<td>
|
||||
${formatTimes(pkt.import_time_us).local}<br>
|
||||
</td>
|
||||
|
||||
<td>
|
||||
<span class="toggle-btn">▶</span>
|
||||
|
||||
@@ -140,8 +140,8 @@ map.on("popupopen", function (e) {
|
||||
if (popupEl) applyTranslationsMap(popupEl);
|
||||
});
|
||||
|
||||
function timeAgo(date){
|
||||
const diff = Date.now() - new Date(date);
|
||||
function timeAgoFromUs(us){
|
||||
const diff = Date.now() - (us / 1000);
|
||||
const s = Math.floor(diff/1000), m = Math.floor(s/60),
|
||||
h = Math.floor(m/60), d = Math.floor(h/24);
|
||||
return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s";
|
||||
@@ -289,7 +289,7 @@ fetch('/api/nodes?days_active=3')
|
||||
hw_model: n.hw_model || "",
|
||||
role: n.role || "",
|
||||
firmware: n.firmware || "",
|
||||
last_update: n.last_update || "",
|
||||
last_seen_us: n.last_seen_us || null,
|
||||
isRouter: (n.role||"").toLowerCase().includes("router")
|
||||
}));
|
||||
|
||||
@@ -333,8 +333,8 @@ function renderNodesOnMap(){
|
||||
<b data-translate-lang="role_label"></b> ${node.role}<br>
|
||||
|
||||
${
|
||||
node.last_update
|
||||
? `<b data-translate-lang="last_seen"></b> ${timeAgo(node.last_update)}<br>`
|
||||
node.last_seen_us
|
||||
? `<b data-translate-lang="last_seen"></b> ${timeAgoFromUs(node.last_seen_us)}<br>`
|
||||
: ""
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
</span>
|
||||
|
||||
<span class="col-3 nodename">
|
||||
<a href="/packet_list/${packet.from_node_id}">
|
||||
<a href="/node/${packet.from_node_id}">
|
||||
${escapeHtml(fromName)}
|
||||
</a>
|
||||
</span>
|
||||
@@ -178,7 +178,7 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
const sinceUs = Math.floor(sixDaysAgoMs * 1000);
|
||||
|
||||
const url =
|
||||
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`;
|
||||
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}&limit=1000`;
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -143,19 +143,29 @@
|
||||
|
||||
<!-- Node Info -->
|
||||
<div id="node-info" class="node-info">
|
||||
<div><strong data-translate-lang="node_id">Node ID</strong><strong>:</strong><span id="info-node-id">—</span></div>
|
||||
<div><strong data-translate-lang="long_name">Long Name</strong><strong>:</strong> <span id="info-long-name">—</span></div>
|
||||
<div><strong data-translate-lang="short_name">Short Name</strong><strong>:</strong> <span id="info-short-name">—</span></div>
|
||||
<div><strong data-translate-lang="node_id">Node ID</strong><strong>: </strong><span id="info-node-id">—</span></div>
|
||||
<div><strong data-translate-lang="id">Hex ID</strong><strong>: </strong><span id="info-id">—</span></div>
|
||||
<div><strong data-translate-lang="long_name">Long Name</strong><strong>: </strong> <span id="info-long-name">—</span></div>
|
||||
<div><strong data-translate-lang="short_name">Short Name</strong><strong>: </strong> <span id="info-short-name">—</span></div>
|
||||
|
||||
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>:</strong> <span id="info-hw-model">—</span></div>
|
||||
<div><strong data-translate-lang="firmware">Firmware</strong><strong>:</strong> <span id="info-firmware">—</span></div>
|
||||
<div><strong data-translate-lang="role">Role</strong><strong>:</strong> <span id="info-role">—</span></div>
|
||||
<div><strong data-translate-lang="hw_model">Hardware Model</strong><strong>: </strong> <span id="info-hw-model">—</span></div>
|
||||
<div><strong data-translate-lang="firmware">Firmware</strong><strong>: </strong> <span id="info-firmware">—</span></div>
|
||||
<div><strong data-translate-lang="role">Role</strong><strong>: </strong> <span id="info-role">—</span></div>
|
||||
|
||||
<div><strong data-translate-lang="channel">Channel</strong><strong>: </strong> <span id="info-channel">—</span></div>
|
||||
<div><strong data-translate-lang="latitude">Latitude</strong><strong>: </strong> <span id="info-lat">—</span></div>
|
||||
<div><strong data-translate-lang="longitude">Longitude</strong><strong>: </strong> <span id="info-lon">—</span></div>
|
||||
|
||||
<div><strong data-translate-lang="last_update">Last Update</strong><strong>: </strong> <span id="info-last-update">—</span></div>
|
||||
<div>
|
||||
<strong data-translate-lang="statistics">Statistics</strong><strong>: </strong>
|
||||
<span id="info-stats"
|
||||
data-label-24h="24h"
|
||||
data-label-sent="Packets sent"
|
||||
data-label-seen="Times seen">—</span>
|
||||
</div>
|
||||
|
||||
<div><strong data-translate-lang="channel">Channel</strong><strong>:</strong> <span id="info-channel">—</span></div>
|
||||
<div><strong data-translate-lang="latitude">Latitude</strong><strong>:</strong> <span id="info-lat">—</span></div>
|
||||
<div><strong data-translate-lang="longitude">Longitude</strong><strong>:</strong> <span id="info-lon">—</span></div>
|
||||
|
||||
<div><strong data-translate-lang="last_update">Last Update</strong><strong>:</strong> <span id="info-last-update">—</span></div>
|
||||
</div>
|
||||
|
||||
<!-- Map. -->
|
||||
@@ -210,18 +220,55 @@
|
||||
<div id="chart_neighbors" style="height:380px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Packet Histogram -->
|
||||
<div id="packet_histogram_container" class="chart-container">
|
||||
<div class="chart-header">
|
||||
📊 <span data-translate-lang="packets_per_day">Packets per Day (Last 7 Days)</span>
|
||||
<div class="chart-actions">
|
||||
<button onclick="expandChart('packet_histogram')" data-translate-lang="expand">Expand</button>
|
||||
<button onclick="exportCSV('packet_histogram')" data-translate-lang="export_csv">Export CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="chart_packet_histogram" style="height:380px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- Packet Filters -->
|
||||
<div class="filter-container" style="margin-bottom:10px; display:flex; gap:12px; flex-wrap:wrap;">
|
||||
<select id="packet_since">
|
||||
<option value="">All time</option>
|
||||
<option value="3600">Last hour</option>
|
||||
<option value="21600">Last 6 hours</option>
|
||||
<option value="86400">Last 24 hours</option>
|
||||
<option value="172800">Last 2 days</option>
|
||||
<option value="259200">Last 3 days</option>
|
||||
<option value="432000">Last 5 days</option>
|
||||
<option value="604800">Last 7 days</option>
|
||||
</select>
|
||||
|
||||
<select id="packet_port">
|
||||
<option value="">All ports</option>
|
||||
</select>
|
||||
|
||||
<button onclick="reloadPackets()">Apply</button>
|
||||
<button onclick="exportPacketsCSV()">Export CSV</button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
<!-- Packets -->
|
||||
<table class="packet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="time">Time</th>
|
||||
<th data-translate-lang="packet_id">Packet ID</th>
|
||||
<th data-translate-lang="from">From</th>
|
||||
<th data-translate-lang="to">To</th>
|
||||
<th data-translate-lang="port">Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="time">Time</th>
|
||||
<th data-translate-lang="packet_id">Packet ID</th>
|
||||
<th data-translate-lang="from">From</th>
|
||||
<th data-translate-lang="to">To</th>
|
||||
<th data-translate-lang="port">Port</th>
|
||||
<th data-translate-lang="size">Size</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody id="packet_list"></tbody>
|
||||
</table>
|
||||
|
||||
@@ -238,8 +285,13 @@
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
|
||||
<script src="/static/portmaps.js"></script>
|
||||
|
||||
<script>
|
||||
|
||||
const PORT_COLOR_MAP = window.PORT_COLOR_MAP || {};
|
||||
const PORT_LABEL_MAP = window.PORT_LABEL_MAP || {};
|
||||
|
||||
/* ======================================================
|
||||
NODE PAGE TRANSLATION (isolated from base)
|
||||
====================================================== */
|
||||
@@ -344,6 +396,7 @@ let nodeMap = {}; // node_id -> label
|
||||
let nodePositions = {}; // node_id -> [lat, lon]
|
||||
let nodeCache = {}; // node_id -> full node object
|
||||
let currentNode = null;
|
||||
let currentPacketRows = [];
|
||||
|
||||
let map, markers = {};
|
||||
let chartData = {}, neighborData = { ids:[], names:[], snrs:[] };
|
||||
@@ -406,6 +459,7 @@ async function loadNodeInfo(){
|
||||
nodeMap[fromNodeId] || fromNodeId;
|
||||
|
||||
// Info card
|
||||
document.getElementById("info-id").textContent = node.id ?? "—";
|
||||
document.getElementById("info-node-id").textContent = node.node_id ?? "—";
|
||||
document.getElementById("info-long-name").textContent = node.long_name ?? "—";
|
||||
document.getElementById("info-short-name").textContent = node.short_name ?? "—";
|
||||
@@ -424,6 +478,7 @@ async function loadNodeInfo(){
|
||||
lastSeen = formatLastSeen(node.last_seen_us);
|
||||
}
|
||||
document.getElementById("info-last-update").textContent = lastSeen;
|
||||
loadNodeStats(node.node_id);
|
||||
} catch (err) {
|
||||
console.error("Failed to load node info:", err);
|
||||
document.getElementById("node-info").style.display = "none";
|
||||
@@ -457,47 +512,27 @@ function nodeLink(id, labelOverride = null) {
|
||||
</a>`;
|
||||
}
|
||||
|
||||
function initPacketPortFilter() {
|
||||
const sel = document.getElementById("packet_port");
|
||||
if (!sel) return;
|
||||
|
||||
Object.keys(PORT_LABEL_MAP)
|
||||
.map(Number)
|
||||
.sort((a, b) => a - b)
|
||||
.forEach(p => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = p;
|
||||
opt.textContent = `${PORT_LABEL_MAP[p]} (${p})`;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/* ======================================================
|
||||
PORT LABELS
|
||||
====================================================== */
|
||||
|
||||
function portLabel(p) {
|
||||
const PORT_COLOR_MAP = {
|
||||
0: "#6c757d",
|
||||
1: "#007bff",
|
||||
3: "#28a745",
|
||||
4: "#ffc107",
|
||||
5: "#dc3545",
|
||||
6: "#20c997",
|
||||
65: "#6610f2",
|
||||
67: "#17a2b8",
|
||||
68: "#fd7e14",
|
||||
69: "#6f42c1",
|
||||
70: "#ff4444",
|
||||
71: "#ff66cc",
|
||||
72: "#00cc99",
|
||||
73: "#9999ff",
|
||||
74: "#cc00cc",
|
||||
75: "#ffbb33",
|
||||
76: "#00bcd4",
|
||||
77: "#8bc34a",
|
||||
78: "#795548"
|
||||
};
|
||||
|
||||
const PORT_LABEL_MAP = {
|
||||
0: "UNKNOWN",
|
||||
1: "Text",
|
||||
3: "Position",
|
||||
4: "Node Info",
|
||||
5: "Routing",
|
||||
6: "Admin",
|
||||
65: "Store & Forward",
|
||||
67: "Telemetry",
|
||||
70: "Traceroute",
|
||||
71: "Neighbor"
|
||||
};
|
||||
|
||||
const color = PORT_COLOR_MAP[p] || "#6c757d";
|
||||
const label = PORT_LABEL_MAP[p] || `Port ${p}`;
|
||||
|
||||
@@ -511,6 +546,7 @@ function portLabel(p) {
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
/* ======================================================
|
||||
MAP SETUP
|
||||
====================================================== */
|
||||
@@ -552,10 +588,16 @@ function addMarker(id, lat, lon, color = "red", node = null) {
|
||||
m.bringToFront();
|
||||
}
|
||||
|
||||
async function drawNeighbors(src, nids){
|
||||
async function drawNeighbors(src, nids) {
|
||||
if (!map) return;
|
||||
const srcPos = nodePositions[src];
|
||||
if (!srcPos) return;
|
||||
|
||||
// Ensure source node position exists
|
||||
const srcNode = await fetchNodeFromApi(src);
|
||||
if (!srcNode || !srcNode.last_lat || !srcNode.last_long) return;
|
||||
|
||||
const srcLat = srcNode.last_lat / 1e7;
|
||||
const srcLon = srcNode.last_long / 1e7;
|
||||
nodePositions[src] = [srcLat, srcLon];
|
||||
|
||||
for (const nid of nids) {
|
||||
const neighbor = await fetchNodeFromApi(nid);
|
||||
@@ -564,13 +606,22 @@ async function drawNeighbors(src, nids){
|
||||
const lat = neighbor.last_lat / 1e7;
|
||||
const lon = neighbor.last_long / 1e7;
|
||||
|
||||
nodePositions[nid] = [lat, lon];
|
||||
|
||||
// Marker
|
||||
addMarker(nid, lat, lon, "blue", neighbor);
|
||||
|
||||
const dstPos = [lat, lon];
|
||||
L.polyline([srcPos, dstPos], { color:'gray', weight:1 }).addTo(map);
|
||||
// Link line
|
||||
L.polyline(
|
||||
[[srcLat, srcLon], [lat, lon]],
|
||||
{ color: "gray", weight: 1 }
|
||||
).addTo(map);
|
||||
}
|
||||
|
||||
ensureMapVisible();
|
||||
}
|
||||
|
||||
|
||||
function ensureMapVisible(){
|
||||
if (!map) return;
|
||||
requestAnimationFrame(() => {
|
||||
@@ -679,72 +730,86 @@ async function loadTrack(){
|
||||
PACKETS TABLE + NEIGHBOR OVERLAY
|
||||
====================================================== */
|
||||
|
||||
async function loadPackets(){
|
||||
async function loadPackets(filters = {}) {
|
||||
const list = document.getElementById("packet_list");
|
||||
list.innerHTML = "";
|
||||
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
url.searchParams.set("node_id", fromNodeId); // node_id includes to/from
|
||||
url.searchParams.set("limit", 200);
|
||||
url.searchParams.set("node_id", fromNodeId);
|
||||
url.searchParams.set("limit", 1000);
|
||||
|
||||
if (filters.since) {
|
||||
url.searchParams.set("since", filters.since);
|
||||
}
|
||||
|
||||
if (filters.portnum) {
|
||||
url.searchParams.set("portnum", filters.portnum);
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const list = document.getElementById("packet_list");
|
||||
const packets = data.packets || [];
|
||||
currentPacketRows = packets;
|
||||
|
||||
for (const pkt of (data.packets || []).reverse()) {
|
||||
const safePayload = (pkt.payload || "").replace(/[<>]/g, m => m === "<" ? "<" : ">");
|
||||
const localTime = formatLocalTime(pkt.import_time_us);
|
||||
const fromCell = nodeLink(pkt.from_node_id,pkt.long_name);
|
||||
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
|
||||
for (const pkt of packets.reverse()) {
|
||||
|
||||
// Neighbor packets (port 71) → draw neighbors on map
|
||||
if (pkt.portnum === 71 && pkt.payload) {
|
||||
const nids = [];
|
||||
const re = /neighbors\s*\{\s*node_id:\s*(\d+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(pkt.payload)) !== null) {
|
||||
nids.push(parseInt(m[1]));
|
||||
}
|
||||
if (nids.length && map) {
|
||||
await drawNeighbors(pkt.from_node_id, nids);
|
||||
}
|
||||
}
|
||||
// ================================
|
||||
// TABLE ROW
|
||||
// ================================
|
||||
const safePayload = (pkt.payload || "")
|
||||
.replace(/[<>]/g, m => (m === "<" ? "<" : ">"));
|
||||
|
||||
const localTime = formatLocalTime(pkt.import_time_us);
|
||||
const fromCell = nodeLink(pkt.from_node_id, pkt.long_name);
|
||||
const toCell = nodeLink(pkt.to_node_id, pkt.to_long_name);
|
||||
|
||||
let inlineLinks = "";
|
||||
|
||||
// Position link (Google Maps)
|
||||
if (pkt.portnum === 3 && pkt.payload) {
|
||||
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
||||
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
||||
|
||||
if (latMatch && lonMatch) {
|
||||
const lat = parseFloat(latMatch[1]) / 1e7;
|
||||
const lon = parseFloat(lonMatch[1]) / 1e7;
|
||||
inlineLinks += ` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
|
||||
inlineLinks +=
|
||||
` <a class="inline-link" href="https://www.google.com/maps?q=${lat},${lon}" target="_blank">📍</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Traceroute link
|
||||
if (pkt.portnum === 70) {
|
||||
let traceId = pkt.id;
|
||||
const match = pkt.payload?.match(/ID:\s*(\d+)/i);
|
||||
if (match) traceId = match[1];
|
||||
inlineLinks += ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
||||
inlineLinks +=
|
||||
` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
|
||||
}
|
||||
|
||||
const sizeBytes = packetSizeBytes(pkt);
|
||||
|
||||
list.insertAdjacentHTML("afterbegin", `
|
||||
<tr class="packet-row">
|
||||
<td>${localTime}</td>
|
||||
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
|
||||
<td>${fromCell}</td>
|
||||
<td>${toCell}</td>
|
||||
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
|
||||
</tr>
|
||||
<tr class="payload-row">
|
||||
<td colspan="5" class="payload-cell">${safePayload}</td>
|
||||
</tr>`);
|
||||
<tr class="packet-row">
|
||||
<td>${localTime}</td>
|
||||
<td><span class="toggle-btn">▶</span>
|
||||
<a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">
|
||||
${pkt.id}
|
||||
</a>
|
||||
</td>
|
||||
<td>${fromCell}</td>
|
||||
<td>${toCell}</td>
|
||||
<td>${portLabel(pkt.portnum)}${inlineLinks}</td>
|
||||
<td>${sizeBytes.toLocaleString()} B</td>
|
||||
</tr>
|
||||
<tr class="payload-row">
|
||||
<td colspan="6" class="payload-cell">${safePayload}</td>
|
||||
</tr>`);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ======================================================
|
||||
TELEMETRY CHARTS (portnum=67)
|
||||
====================================================== */
|
||||
@@ -907,46 +972,69 @@ async function loadTelemetryCharts(){
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
async function loadLatestNeighborIds() {
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
url.searchParams.set("from_node_id", fromNodeId);
|
||||
url.searchParams.set("portnum", 71);
|
||||
url.searchParams.set("limit", 1); // ✅ ONLY the latest packet
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return [];
|
||||
|
||||
const data = await res.json();
|
||||
const pkt = data.packets?.[0];
|
||||
if (!pkt || !pkt.payload) return [];
|
||||
|
||||
const ids = [];
|
||||
const re = /neighbors\s*\{([^}]+)\}/g;
|
||||
let m;
|
||||
|
||||
while ((m = re.exec(pkt.payload)) !== null) {
|
||||
const id = m[1].match(/node_id:\s*(\d+)/);
|
||||
if (id) ids.push(parseInt(id[1], 10));
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
NEIGHBOR CHART (portnum=71)
|
||||
====================================================== */
|
||||
|
||||
async function loadNeighborTimeSeries() {
|
||||
const container = document.getElementById("neighbor_chart_container");
|
||||
const chartEl = document.getElementById("chart_neighbors");
|
||||
|
||||
const url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=500`;
|
||||
const res = await fetch(url);
|
||||
|
||||
if (!res.ok) {
|
||||
document.getElementById("neighbor_chart_container").style.display = "none";
|
||||
container.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
let packets = data.packets || [];
|
||||
const packets = data.packets || [];
|
||||
|
||||
if (!packets.length) {
|
||||
document.getElementById("neighbor_chart_container").style.display = "none";
|
||||
container.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
// --- FIX #1: enforce chronological order (oldest → newest) ---
|
||||
// Sort packets chronologically (microseconds)
|
||||
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
|
||||
|
||||
// neighborHistory = { node_id: { name, snr:[...], times:[...] } }
|
||||
const neighborHistory = {};
|
||||
const neighborHistory = {}; // node_id -> { name, times[], snr[] }
|
||||
|
||||
for (const pkt of packets) {
|
||||
if (!pkt.import_time_us || !pkt.payload) continue;
|
||||
|
||||
const ts = new Date(pkt.import_time_us / 1000).toLocaleString([], {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
});
|
||||
const ts = pkt.import_time_us; // KEEP NUMERIC TIMESTAMP
|
||||
|
||||
// Extract neighbor blocks
|
||||
const blockRe = /neighbors\s*\{([^}]+)\}/g;
|
||||
let m;
|
||||
|
||||
while ((m = blockRe.exec(pkt.payload)) !== null) {
|
||||
const block = m[1];
|
||||
|
||||
@@ -958,9 +1046,14 @@ async function loadNeighborTimeSeries() {
|
||||
const nid = parseInt(idMatch[1], 10);
|
||||
const snr = parseFloat(snrMatch[1]);
|
||||
|
||||
// Fetch neighbor metadata once
|
||||
const neighbor = await fetchNodeFromApi(nid);
|
||||
|
||||
if (!neighborHistory[nid]) {
|
||||
neighborHistory[nid] = {
|
||||
name: nodeMap[nid] || `Node ${nid}`,
|
||||
name: neighbor?.short_name ||
|
||||
neighbor?.long_name ||
|
||||
`Node ${nid}`,
|
||||
times: [],
|
||||
snr: []
|
||||
};
|
||||
@@ -971,45 +1064,59 @@ async function loadNeighborTimeSeries() {
|
||||
}
|
||||
}
|
||||
|
||||
const chart = echarts.init(document.getElementById("chart_neighbors"));
|
||||
// Collect ALL timestamps across neighbors
|
||||
const allTimes = new Set();
|
||||
Object.values(neighborHistory).forEach(entry => {
|
||||
entry.times.forEach(t => allTimes.add(t));
|
||||
});
|
||||
|
||||
// Sort timestamps numerically
|
||||
const xTimes = Array.from(allTimes).sort((a, b) => a - b);
|
||||
|
||||
const legend = [];
|
||||
const series = [];
|
||||
|
||||
for (const [nid, entry] of Object.entries(neighborHistory)) {
|
||||
for (const entry of Object.values(neighborHistory)) {
|
||||
legend.push(entry.name);
|
||||
|
||||
series.push({
|
||||
name: entry.name,
|
||||
type: "line",
|
||||
smooth: true,
|
||||
connectNulls: true, // --- FIX #2: connect dots even if missing ---
|
||||
connectNulls: true,
|
||||
showSymbol: false,
|
||||
data: entry.snr,
|
||||
data: xTimes.map(t => {
|
||||
const idx = entry.times.indexOf(t);
|
||||
return idx >= 0 ? entry.snr[idx] : null;
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Collect all timestamps from all neighbors
|
||||
const allTimesSet = new Set();
|
||||
|
||||
for (const entry of Object.values(neighborHistory)) {
|
||||
for (const t of entry.times) {
|
||||
allTimesSet.add(t);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to array and sort chronologically
|
||||
const sampleTimes = Array.from(allTimesSet).sort((a, b) => {
|
||||
return new Date(a) - new Date(b);
|
||||
});
|
||||
const chart = echarts.init(chartEl);
|
||||
|
||||
chart.setOption({
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { data: legend, textStyle: { color: "#ccc" } },
|
||||
tooltip: {
|
||||
trigger: "axis",
|
||||
axisPointer: { type: "line" }
|
||||
},
|
||||
legend: {
|
||||
data: legend,
|
||||
textStyle: { color: "#ccc" }
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: sampleTimes,
|
||||
axisLabel: { color: "#ccc" }
|
||||
data: xTimes,
|
||||
axisLabel: {
|
||||
color: "#ccc",
|
||||
formatter: value =>
|
||||
new Date(value / 1000).toLocaleString([], {
|
||||
year: "2-digit",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
})
|
||||
}
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
@@ -1024,6 +1131,100 @@ async function loadNeighborTimeSeries() {
|
||||
|
||||
|
||||
|
||||
async function loadPacketHistogram() {
|
||||
const DAYS = 7;
|
||||
const now = new Date();
|
||||
|
||||
const dayKeys = [];
|
||||
const dayLabels = [];
|
||||
|
||||
for (let i = DAYS - 1; i >= 0; i--) {
|
||||
const d = new Date(now);
|
||||
d.setDate(d.getDate() - i);
|
||||
dayKeys.push(d.toISOString().slice(0, 10));
|
||||
dayLabels.push(
|
||||
d.toLocaleDateString([], { month: "short", day: "numeric" })
|
||||
);
|
||||
}
|
||||
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
url.searchParams.set("node_id", fromNodeId);
|
||||
|
||||
// last 7 days only (microseconds)
|
||||
const sinceUs = Date.now() * 1000 - (7 * 24 * 60 * 60 * 1_000_000);
|
||||
url.searchParams.set("since", sinceUs);
|
||||
|
||||
// modest safety limit (still applies after server-side filter)
|
||||
url.searchParams.set("limit", 2000);
|
||||
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
|
||||
const packets = (await res.json()).packets || [];
|
||||
|
||||
const counts = {}; // { port: { day: count } }
|
||||
const ports = new Set();
|
||||
|
||||
for (const pkt of packets) {
|
||||
if (!pkt.import_time_us) continue;
|
||||
|
||||
const day = new Date(pkt.import_time_us / 1000)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
|
||||
if (!dayKeys.includes(day)) continue;
|
||||
|
||||
const port = pkt.portnum ?? 0;
|
||||
ports.add(port);
|
||||
|
||||
counts[port] ??= {};
|
||||
counts[port][day] = (counts[port][day] || 0) + 1;
|
||||
}
|
||||
|
||||
if (!ports.size) {
|
||||
document.getElementById("packet_histogram_container").style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const series = Array.from(ports)
|
||||
.sort((a, b) => a - b)
|
||||
.map(port => ({
|
||||
name: PORT_LABEL_MAP[port] || `Port ${port}`,
|
||||
type: "bar",
|
||||
stack: "total",
|
||||
barMaxWidth: 42,
|
||||
itemStyle: {
|
||||
color: PORT_COLOR_MAP[port] || "#888"
|
||||
},
|
||||
data: dayKeys.map(d => counts[port]?.[d] || 0)
|
||||
}));
|
||||
|
||||
const chart = echarts.init(
|
||||
document.getElementById("chart_packet_histogram")
|
||||
);
|
||||
|
||||
chart.setOption({
|
||||
animation: false,
|
||||
tooltip: { trigger: "axis" },
|
||||
legend: { textStyle: { color: "#ccc" } },
|
||||
xAxis: {
|
||||
type: "category",
|
||||
data: dayLabels,
|
||||
axisLabel: { color: "#ccc" }
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
axisLabel: { color: "#ccc" }
|
||||
},
|
||||
series
|
||||
});
|
||||
|
||||
window.addEventListener("resize", () => chart.resize());
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* ======================================================
|
||||
EXPAND / EXPORT BUTTONS
|
||||
====================================================== */
|
||||
@@ -1103,15 +1304,29 @@ document.addEventListener("click", e => {
|
||||
====================================================== */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadTranslationsNode(); // translations first
|
||||
await loadTranslationsNode();
|
||||
|
||||
requestAnimationFrame(async () => {
|
||||
await loadNodeInfo(); // single-node fetch
|
||||
if (!map) initMap(); // init map early so neighbors can draw
|
||||
await loadNodeInfo();
|
||||
|
||||
// ✅ MAP MUST EXIST FIRST
|
||||
if (!map) initMap();
|
||||
|
||||
// ✅ DRAW LATEST NEIGHBORS ONCE
|
||||
const neighborIds = await loadLatestNeighborIds();
|
||||
if (neighborIds.length) {
|
||||
await drawNeighbors(fromNodeId, neighborIds);
|
||||
}
|
||||
|
||||
// ⚠️ Track may add to map, but must not hide it
|
||||
await loadTrack();
|
||||
|
||||
await loadPackets();
|
||||
initPacketPortFilter();
|
||||
await loadTelemetryCharts();
|
||||
await loadNeighborTimeSeries();
|
||||
await loadPacketHistogram();
|
||||
|
||||
ensureMapVisible();
|
||||
setTimeout(ensureMapVisible, 1000);
|
||||
window.addEventListener("resize", ensureMapVisible);
|
||||
@@ -1119,5 +1334,109 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
function packetSizeBytes(pkt) {
|
||||
if (!pkt) return 0;
|
||||
|
||||
// Prefer raw payload length
|
||||
if (pkt.payload) {
|
||||
return new TextEncoder().encode(pkt.payload).length;
|
||||
}
|
||||
|
||||
// Fallbacks (if you later add protobuf/base64)
|
||||
if (pkt.raw_payload) {
|
||||
return atob(pkt.raw_payload).length;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
async function loadNodeStats(nodeId) {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
const packets = data?.total_packets ?? 0;
|
||||
const seen = data?.total_seen ?? 0;
|
||||
|
||||
document.getElementById("info-stats").textContent =
|
||||
`24h · Packets sent: ${packets.toLocaleString()} · Times seen: ${seen.toLocaleString()} `;
|
||||
} catch (err) {
|
||||
console.error("Failed to load node stats:", err);
|
||||
document.getElementById("info-stats").textContent = "—";
|
||||
}
|
||||
}
|
||||
|
||||
function reloadPackets() {
|
||||
const sinceSel = document.getElementById("packet_since").value;
|
||||
const portSel = document.getElementById("packet_port").value;
|
||||
|
||||
const filters = {};
|
||||
|
||||
if (sinceSel) {
|
||||
const sinceUs = Date.now() * 1000 - (parseInt(sinceSel, 10) * 1_000_000);
|
||||
filters.since = sinceUs;
|
||||
}
|
||||
|
||||
if (portSel) {
|
||||
filters.portnum = portSel;
|
||||
}
|
||||
|
||||
loadPackets(filters);
|
||||
}
|
||||
|
||||
function exportPacketsCSV() {
|
||||
if (!currentPacketRows.length) {
|
||||
alert("No packets to export.");
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
["Time", "Packet ID", "From Node", "To Node", "Port", "Port Name", "Payload"]
|
||||
];
|
||||
|
||||
for (const pkt of currentPacketRows) {
|
||||
const time = pkt.import_time_us
|
||||
? new Date(pkt.import_time_us / 1000).toISOString()
|
||||
: "";
|
||||
|
||||
const portName = PORT_LABEL_MAP[pkt.portnum] || `Port ${pkt.portnum}`;
|
||||
|
||||
// Escape quotes + line breaks for CSV safety
|
||||
const payload = (pkt.payload || "")
|
||||
.replace(/"/g, '""')
|
||||
.replace(/\r?\n/g, " ");
|
||||
|
||||
rows.push([
|
||||
time,
|
||||
pkt.id,
|
||||
pkt.from_node_id,
|
||||
pkt.to_node_id,
|
||||
pkt.portnum,
|
||||
portName,
|
||||
`"${payload}"`
|
||||
]);
|
||||
}
|
||||
|
||||
const csv = rows.map(r => r.join(",")).join("\n");
|
||||
const blob = new Blob([csv], { type: "text/csv" });
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `packets_${fromNodeId}_${Date.now()}.csv`;
|
||||
link.click();
|
||||
}
|
||||
|
||||
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,23 +2,30 @@
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
html, body {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
/* FIX: allow table to keep natural width so scrolling works */
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
/* Ensure table centered visually */
|
||||
/* Desktop scroll wrapper */
|
||||
#node-list {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
overflow-x: auto; /* allows horizontal scroll */
|
||||
overflow-y: hidden;
|
||||
/* !!! removed display:flex because it prevents scrolling */
|
||||
}
|
||||
|
||||
#node-list table {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
width: max-content; /* table keeps its natural width */
|
||||
min-width: 100%; /* won't shrink smaller than viewport */
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 10px;
|
||||
border: 1px solid #333;
|
||||
@@ -96,6 +103,21 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
.node-status {
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2a6a8a;
|
||||
background: #0d2a3a;
|
||||
color: #9fd4ff;
|
||||
font-size: 0.9em;
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
.node-status.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Favorite stars */
|
||||
.favorite-star {
|
||||
@@ -134,16 +156,20 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
/* --------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Hide desktop table */
|
||||
/* Hide desktop view */
|
||||
#node-list table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show mobile card list */
|
||||
/* Show mobile cards */
|
||||
#mobile-node-list {
|
||||
display: block !important;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
|
||||
/* If you want horizontal swiping, uncomment:
|
||||
overflow-x: auto;
|
||||
white-space: nowrap; */
|
||||
}
|
||||
|
||||
.node-card {
|
||||
@@ -188,7 +214,7 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
id="search-box"
|
||||
class="search-box"
|
||||
data-translate-lang="search_placeholder"
|
||||
placeholder="Search by name or ID..."
|
||||
placeholder="Search by name or ID or HEX ID..."
|
||||
/>
|
||||
|
||||
<select id="role-filter">
|
||||
@@ -224,6 +250,7 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
<span data-translate-lang="showing_nodes">Showing</span>
|
||||
<span id="node-count">0</span>
|
||||
<span data-translate-lang="nodes_suffix">nodes</span>
|
||||
<span id="node-status" class="node-status" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
@@ -294,6 +321,11 @@ let allNodes = [];
|
||||
let sortColumn = "short_name";
|
||||
let sortAsc = true;
|
||||
let showOnlyFavorites = false;
|
||||
let favoritesSet = new Set();
|
||||
let isBusy = false;
|
||||
let statusHideTimer = null;
|
||||
let statusShownAt = 0;
|
||||
const minStatusMs = 300;
|
||||
|
||||
const headers = document.querySelectorAll("thead th");
|
||||
const keyMap = [
|
||||
@@ -301,28 +333,51 @@ const keyMap = [
|
||||
"last_lat","last_long","channel","last_seen_us"
|
||||
];
|
||||
|
||||
function getFavorites() {
|
||||
const favorites = localStorage.getItem('nodelist_favorites');
|
||||
return favorites ? JSON.parse(favorites) : [];
|
||||
}
|
||||
function saveFavorites(favs) {
|
||||
localStorage.setItem('nodelist_favorites', JSON.stringify(favs));
|
||||
}
|
||||
function toggleFavorite(nodeId) {
|
||||
let favs = getFavorites();
|
||||
const idx = favs.indexOf(nodeId);
|
||||
if (idx >= 0) favs.splice(idx, 1);
|
||||
else favs.push(nodeId);
|
||||
saveFavorites(favs);
|
||||
}
|
||||
function isFavorite(nodeId) {
|
||||
return getFavorites().includes(nodeId);
|
||||
function debounce(fn, delay = 250) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
function timeAgo(usTimestamp) {
|
||||
if (!usTimestamp) return "N/A";
|
||||
const ms = usTimestamp / 1000;
|
||||
const diff = Date.now() - ms;
|
||||
function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function loadFavorites() {
|
||||
const favorites = localStorage.getItem('nodelist_favorites');
|
||||
if (!favorites) {
|
||||
favoritesSet = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(favorites);
|
||||
favoritesSet = new Set(Array.isArray(parsed) ? parsed : []);
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse favorites, resetting.", err);
|
||||
favoritesSet = new Set();
|
||||
}
|
||||
}
|
||||
function saveFavorites() {
|
||||
localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet]));
|
||||
}
|
||||
function toggleFavorite(nodeId) {
|
||||
if (favoritesSet.has(nodeId)) {
|
||||
favoritesSet.delete(nodeId);
|
||||
} else {
|
||||
favoritesSet.add(nodeId);
|
||||
}
|
||||
saveFavorites();
|
||||
}
|
||||
function isFavorite(nodeId) {
|
||||
return favoritesSet.has(nodeId);
|
||||
}
|
||||
|
||||
function timeAgoFromMs(msTimestamp) {
|
||||
if (!msTimestamp) return "N/A";
|
||||
const diff = Date.now() - msTimestamp;
|
||||
|
||||
if (diff < 60000) return "just now";
|
||||
const mins = Math.floor(diff / 60000);
|
||||
@@ -339,6 +394,7 @@ function timeAgo(usTimestamp) {
|
||||
document.addEventListener("DOMContentLoaded", async function() {
|
||||
|
||||
await loadTranslationsNodelist();
|
||||
loadFavorites();
|
||||
|
||||
const tbody = document.getElementById("node-table-body");
|
||||
const mobileList = document.getElementById("mobile-node-list");
|
||||
@@ -349,52 +405,82 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
const firmwareFilter = document.getElementById("firmware-filter");
|
||||
const searchBox = document.getElementById("search-box");
|
||||
const countSpan = document.getElementById("node-count");
|
||||
const statusSpan = document.getElementById("node-status");
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
const clearBtn = document.getElementById("clear-btn");
|
||||
const favoritesBtn = document.getElementById("favorites-btn");
|
||||
|
||||
let lastIsMobile = (window.innerWidth <= 768);
|
||||
|
||||
try {
|
||||
setStatus("Loading nodes…");
|
||||
await nextFrame();
|
||||
const res = await fetch("/api/nodes?days_active=3");
|
||||
if (!res.ok) throw new Error("Failed to fetch nodes");
|
||||
|
||||
const data = await res.json();
|
||||
allNodes = data.nodes.map(n => ({
|
||||
...n,
|
||||
firmware: n.firmware || n.firmware_version || ""
|
||||
}));
|
||||
|
||||
allNodes = data.nodes.map(n => {
|
||||
const firmware = n.firmware || n.firmware_version || "";
|
||||
const last_seen_us = n.last_seen_us || 0;
|
||||
const last_seen_ms = last_seen_us ? (last_seen_us / 1000) : 0;
|
||||
|
||||
return {
|
||||
...n,
|
||||
firmware,
|
||||
last_seen_us,
|
||||
last_seen_ms,
|
||||
_search: [
|
||||
n.node_id,
|
||||
n.id,
|
||||
n.long_name,
|
||||
n.short_name
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
};
|
||||
});
|
||||
|
||||
populateFilters(allNodes);
|
||||
renderTable(allNodes);
|
||||
applyFilters(); // ensures initial sort + render uses same path
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr>
|
||||
<td colspan="10" style="text-align:center; color:red;">
|
||||
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
||||
</td></tr>`;
|
||||
setStatus("");
|
||||
return;
|
||||
}
|
||||
|
||||
roleFilter.addEventListener("change", applyFilters);
|
||||
channelFilter.addEventListener("change", applyFilters);
|
||||
hwFilter.addEventListener("change", applyFilters);
|
||||
firmwareFilter.addEventListener("change", applyFilters);
|
||||
searchBox.addEventListener("input", applyFilters);
|
||||
|
||||
// Debounced only for search typing
|
||||
searchBox.addEventListener("input", debounce(applyFilters, 250));
|
||||
|
||||
exportBtn.addEventListener("click", exportToCSV);
|
||||
clearBtn.addEventListener("click", clearFilters);
|
||||
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
|
||||
|
||||
// Favorite star click handler
|
||||
// Favorite star click handler (delegated)
|
||||
document.addEventListener("click", e => {
|
||||
if (e.target.classList.contains('favorite-star')) {
|
||||
const nodeId = parseInt(e.target.dataset.nodeId);
|
||||
const isFav = isFavorite(nodeId);
|
||||
const nodeId = parseInt(e.target.dataset.nodeId, 10);
|
||||
const fav = isFavorite(nodeId);
|
||||
|
||||
if (isFav) {
|
||||
if (fav) {
|
||||
e.target.classList.remove("active");
|
||||
e.target.textContent = "☆";
|
||||
} else {
|
||||
e.target.classList.add("active");
|
||||
e.target.textContent = "★";
|
||||
}
|
||||
|
||||
toggleFavorite(nodeId);
|
||||
applyFilters();
|
||||
}
|
||||
@@ -402,13 +488,26 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
|
||||
headers.forEach((th, index) => {
|
||||
th.addEventListener("click", () => {
|
||||
let key = keyMap[index];
|
||||
const key = keyMap[index];
|
||||
// ignore clicks on the "favorite" (last header) which has no sort key
|
||||
if (!key) return;
|
||||
|
||||
sortAsc = (sortColumn === key) ? !sortAsc : true;
|
||||
sortColumn = key;
|
||||
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
// Re-render on breakpoint change so mobile/desktop view switches instantly
|
||||
window.addEventListener("resize", debounce(() => {
|
||||
const isMobile = (window.innerWidth <= 768);
|
||||
if (isMobile !== lastIsMobile) {
|
||||
lastIsMobile = isMobile;
|
||||
applyFilters();
|
||||
}
|
||||
}, 150));
|
||||
|
||||
function populateFilters(nodes) {
|
||||
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
|
||||
|
||||
@@ -443,7 +542,9 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
async function applyFilters() {
|
||||
setStatus("Updating…");
|
||||
await nextFrame();
|
||||
const searchTerm = searchBox.value.trim().toLowerCase();
|
||||
|
||||
let filtered = allNodes.filter(n => {
|
||||
@@ -452,102 +553,116 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value;
|
||||
const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value;
|
||||
|
||||
const searchMatch =
|
||||
!searchTerm ||
|
||||
(n.long_name && n.long_name.toLowerCase().includes(searchTerm)) ||
|
||||
(n.short_name && n.short_name.toLowerCase().includes(searchTerm)) ||
|
||||
n.node_id.toString().includes(searchTerm);
|
||||
|
||||
const searchMatch = !searchTerm || n._search.includes(searchTerm);
|
||||
const favMatch = !showOnlyFavorites || isFavorite(n.node_id);
|
||||
|
||||
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
|
||||
});
|
||||
|
||||
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
|
||||
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
||||
|
||||
renderTable(filtered);
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
function renderTable(nodes) {
|
||||
tbody.innerHTML = "";
|
||||
mobileList.innerHTML = "";
|
||||
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const shouldRenderTable = !isMobile;
|
||||
|
||||
if (shouldRenderTable) {
|
||||
tbody.innerHTML = "";
|
||||
} else {
|
||||
mobileList.innerHTML = "";
|
||||
}
|
||||
|
||||
const tableFrag = shouldRenderTable ? document.createDocumentFragment() : null;
|
||||
const mobileFrag = shouldRenderTable ? null : document.createDocumentFragment();
|
||||
|
||||
if (!nodes.length) {
|
||||
tbody.innerHTML = `<tr>
|
||||
<td colspan="10" style="text-align:center; color:white;">
|
||||
if (shouldRenderTable) {
|
||||
tbody.innerHTML = `<tr>
|
||||
<td colspan="10" style="text-align:center; color:white;">
|
||||
${nodelistTranslations.no_nodes_found || "No nodes found"}
|
||||
</td>
|
||||
</tr>`;
|
||||
} else {
|
||||
mobileList.innerHTML = `<div style="text-align:center; color:white;">
|
||||
${nodelistTranslations.no_nodes_found || "No nodes found"}
|
||||
</td>
|
||||
</tr>`;
|
||||
</div>`;
|
||||
}
|
||||
|
||||
mobileList.innerHTML = `<div style="text-align:center; color:white;">No nodes found</div>`;
|
||||
countSpan.textContent = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach(node => {
|
||||
const isFav = isFavorite(node.node_id);
|
||||
const star = isFav ? "★" : "☆";
|
||||
const fav = isFavorite(node.node_id);
|
||||
const star = fav ? "★" : "☆";
|
||||
|
||||
// DESKTOP TABLE ROW
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${node.short_name || "N/A"}</td>
|
||||
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
|
||||
<td>${node.hw_model || "N/A"}</td>
|
||||
<td>${node.firmware || "N/A"}</td>
|
||||
<td>${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.channel || "N/A"}</td>
|
||||
<td>${timeAgo(node.last_seen_us)}</td>
|
||||
<td style="text-align:center;">
|
||||
<span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
if (shouldRenderTable) {
|
||||
// DESKTOP TABLE ROW
|
||||
const row = document.createElement("tr");
|
||||
row.innerHTML = `
|
||||
<td>${node.short_name || "N/A"}</td>
|
||||
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
|
||||
<td>${node.hw_model || "N/A"}</td>
|
||||
<td>${node.firmware || "N/A"}</td>
|
||||
<td>${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.channel || "N/A"}</td>
|
||||
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
|
||||
<td style="text-align:center;">
|
||||
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
tableFrag.appendChild(row);
|
||||
} else {
|
||||
// MOBILE CARD VIEW
|
||||
const card = document.createElement("div");
|
||||
card.className = "node-card";
|
||||
card.innerHTML = `
|
||||
<div class="node-card-header">
|
||||
<span>${node.short_name || node.long_name || node.node_id}</span>
|
||||
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
// MOBILE CARD VIEW
|
||||
const card = document.createElement("div");
|
||||
card.className = "node-card";
|
||||
card.innerHTML = `
|
||||
<div class="node-card-header">
|
||||
<span>${node.short_name || node.long_name || node.node_id}</span>
|
||||
<span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</div>
|
||||
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
|
||||
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
|
||||
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Location:</b>
|
||||
${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>Channel:</b> ${node.channel || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
|
||||
|
||||
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
|
||||
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
|
||||
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Location:</b>
|
||||
${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>Channel:</b> ${node.channel}</div>
|
||||
<div class="node-card-field"><b>Last Seen:</b> ${timeAgo(node.last_seen_us)}</div>
|
||||
|
||||
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
|
||||
View Node →
|
||||
</a>
|
||||
`;
|
||||
mobileList.appendChild(card);
|
||||
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
|
||||
View Node →
|
||||
</a>
|
||||
`;
|
||||
mobileFrag.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle correct view
|
||||
if (isMobile) {
|
||||
mobileList.style.display = "block";
|
||||
} else {
|
||||
mobileList.style.display = "none";
|
||||
}
|
||||
mobileList.style.display = isMobile ? "block" : "none";
|
||||
|
||||
countSpan.textContent = nodes.length;
|
||||
|
||||
if (shouldRenderTable) {
|
||||
tbody.appendChild(tableFrag);
|
||||
} else {
|
||||
mobileList.appendChild(mobileFrag);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
@@ -556,6 +671,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
hwFilter.value = "";
|
||||
firmwareFilter.value = "";
|
||||
searchBox.value = "";
|
||||
|
||||
sortColumn = "short_name";
|
||||
sortAsc = true;
|
||||
showOnlyFavorites = false;
|
||||
@@ -563,7 +679,7 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
favoritesBtn.textContent = "⭐ Show Favorites";
|
||||
favoritesBtn.classList.remove("active");
|
||||
|
||||
renderTable(allNodes);
|
||||
applyFilters();
|
||||
updateSortIcons();
|
||||
}
|
||||
|
||||
@@ -599,6 +715,10 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
B = B || 0;
|
||||
}
|
||||
|
||||
// Normalize strings for stable sorting
|
||||
if (typeof A === "string") A = A.toLowerCase();
|
||||
if (typeof B === "string") B = B.toLowerCase();
|
||||
|
||||
if (A < B) return asc ? -1 : 1;
|
||||
if (A > B) return asc ? 1 : -1;
|
||||
return 0;
|
||||
@@ -613,6 +733,41 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : "";
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (!statusSpan) return;
|
||||
if (statusHideTimer) {
|
||||
clearTimeout(statusHideTimer);
|
||||
statusHideTimer = null;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
statusShownAt = Date.now();
|
||||
console.log("[nodelist] status:", message);
|
||||
statusSpan.textContent = message;
|
||||
statusSpan.classList.add("active");
|
||||
isBusy = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - statusShownAt;
|
||||
const remaining = Math.max(0, minStatusMs - elapsed);
|
||||
if (remaining > 0) {
|
||||
statusHideTimer = setTimeout(() => {
|
||||
statusHideTimer = null;
|
||||
console.log("[nodelist] status: cleared");
|
||||
statusSpan.textContent = "";
|
||||
statusSpan.classList.remove("active");
|
||||
isBusy = false;
|
||||
}, remaining);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[nodelist] status: cleared");
|
||||
statusSpan.textContent = "";
|
||||
statusSpan.classList.remove("active");
|
||||
isBusy = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -380,103 +380,150 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
Load packets_seen
|
||||
----------------------------------------------*/
|
||||
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
|
||||
const seenData = await seenRes.json();
|
||||
const seenList = seenData.seen ?? [];
|
||||
Load packets_seen
|
||||
----------------------------------------------*/
|
||||
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
|
||||
const seenData = await seenRes.json();
|
||||
const seenList = seenData.seen ?? [];
|
||||
|
||||
const seenSorted = seenList.slice().sort((a,b)=>{
|
||||
return (b.hop_start ?? -999) - (a.hop_start ?? -999);
|
||||
});
|
||||
/* ---------------------------------------------
|
||||
Sort by hop count (highest first)
|
||||
----------------------------------------------*/
|
||||
const seenSorted = seenList.slice().sort((a,b)=>{
|
||||
const ha = (a.hop_start ?? 0) - (a.hop_limit ?? 0);
|
||||
const hb = (b.hop_start ?? 0) - (b.hop_limit ?? 0);
|
||||
return hb - ha;
|
||||
});
|
||||
|
||||
if (seenSorted.length){
|
||||
seenContainer.classList.remove("d-none");
|
||||
seenCountSpan.textContent = `(${seenSorted.length})`;
|
||||
}
|
||||
if (seenSorted.length){
|
||||
seenContainer.classList.remove("d-none");
|
||||
seenCountSpan.textContent = `(${seenSorted.length})`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
Render gateway table + map markers
|
||||
----------------------------------------------*/
|
||||
seenTableBody.innerHTML = seenSorted.map(s=>{
|
||||
const node = nodeLookup[s.node_id];
|
||||
const label = node?.long_name || s.node_id;
|
||||
/* ---------------------------------------------
|
||||
GROUP BY HOP COUNT
|
||||
----------------------------------------------*/
|
||||
const hopGroups = {};
|
||||
|
||||
const timeStr = s.import_time_us
|
||||
? new Date(s.import_time_us/1000).toLocaleTimeString()
|
||||
: "—";
|
||||
seenSorted.forEach(s => {
|
||||
const hopValue = Math.max(
|
||||
0,
|
||||
(s.hop_start ?? 0) - (s.hop_limit ?? 0)
|
||||
);
|
||||
if (!hopGroups[hopValue]) hopGroups[hopValue] = [];
|
||||
hopGroups[hopValue].push(s);
|
||||
});
|
||||
|
||||
if (node?.last_lat && node.last_long){
|
||||
const rlat = node.last_lat/1e7;
|
||||
const rlon = node.last_long/1e7;
|
||||
allBounds.push([rlat, rlon]);
|
||||
/* ---------------------------------------------
|
||||
Render grouped gateway table + map markers
|
||||
----------------------------------------------*/
|
||||
seenTableBody.innerHTML = Object.keys(hopGroups)
|
||||
.sort((a,b) => Number(a) - Number(b)) // 0 hop first
|
||||
.map(hopKey => {
|
||||
|
||||
const hopValue = (s.hop_start ?? 0) - (s.hop_limit ?? 0);
|
||||
const color = hopColor(hopValue);
|
||||
const hopLabel =
|
||||
hopKey === "0"
|
||||
? (packetTranslations.direct || "Direct (0 hops)")
|
||||
: `${hopKey} ${packetTranslations.hops || "hops"}`;
|
||||
|
||||
const marker = L.marker([rlat,rlon],{
|
||||
icon: L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
background:${color};
|
||||
width:24px; height:24px;
|
||||
border-radius:50%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color:white;
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
border:2px solid rgba(0,0,0,0.35);
|
||||
box-shadow:0 0 5px rgba(0,0,0,0.45);
|
||||
">${hopValue}</div>`,
|
||||
className: "",
|
||||
iconSize:[24,24],
|
||||
iconAnchor:[12,12]
|
||||
})
|
||||
}).addTo(map);
|
||||
const rows = hopGroups[hopKey].map(s => {
|
||||
const node = nodeLookup[s.node_id];
|
||||
const label = node?.long_name || s.node_id;
|
||||
|
||||
let distKm = null, distMi = null;
|
||||
if (srcLat && srcLon){
|
||||
distKm = haversine(srcLat, srcLon, rlat, rlon);
|
||||
distMi = distKm * 0.621371;
|
||||
}
|
||||
const timeStr = s.import_time_us
|
||||
? new Date(s.import_time_us/1000).toLocaleTimeString()
|
||||
: "—";
|
||||
|
||||
marker.bindPopup(`
|
||||
/* ---------------- MAP MARKERS (UNCHANGED) ---------------- */
|
||||
if (node?.last_lat && node.last_long){
|
||||
const rlat = node.last_lat/1e7;
|
||||
const rlon = node.last_long/1e7;
|
||||
allBounds.push([rlat, rlon]);
|
||||
let distanceKm = null;
|
||||
if (srcLat && srcLon) {
|
||||
distanceKm = haversine(srcLat, srcLon, rlat, rlon);
|
||||
}
|
||||
const distanceMi = distanceKm !== null ? distanceKm * 0.621371 : null;
|
||||
const color = hopColor(hopKey);
|
||||
|
||||
const marker = L.marker([rlat,rlon],{
|
||||
icon: L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
background:${color};
|
||||
width:24px; height:24px;
|
||||
border-radius:50%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color:white;
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
border:2px solid rgba(0,0,0,0.35);
|
||||
box-shadow:0 0 5px rgba(0,0,0,0.45);
|
||||
">${hopKey}</div>`,
|
||||
className: "",
|
||||
iconSize:[24,24],
|
||||
iconAnchor:[12,12]
|
||||
})
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-size:0.9em">
|
||||
<b>${label}</b><br>
|
||||
<span data-translate-lang="node_id_short">${packetTranslations.node_id_short || "Node ID"}</span>:
|
||||
<span data-translate-lang="node_id_short">Node ID</span>:
|
||||
<a href="/node/${s.node_id}">${s.node_id}</a><br>
|
||||
HW: ${node?.hw_model ?? "—"}<br>
|
||||
<span data-translate-lang="channel">${packetTranslations.channel || "Channel"}</span>: ${s.channel ?? "—"}<br><br>
|
||||
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
|
||||
${
|
||||
distanceKm !== null
|
||||
? `<span data-translate-lang="distance">Distance</span>:
|
||||
${distanceKm.toFixed(1)} km / ${distanceMi.toFixed(1)} mi<br>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<b data-translate-lang="signal">${packetTranslations.signal || "Signal"}</b><br>
|
||||
<br>
|
||||
|
||||
<b data-translate-lang="signal">Signal</b><br>
|
||||
RSSI: ${s.rx_rssi ?? "—"}<br>
|
||||
SNR: ${s.rx_snr ?? "—"}<br><br>
|
||||
|
||||
<b data-translate-lang="hops">${packetTranslations.hops || "Hops"}</b>: ${hopValue}<br>
|
||||
|
||||
<b data-translate-lang="distance">${packetTranslations.distance || "Distance"}:</b><br>
|
||||
${
|
||||
distKm
|
||||
? `${distKm.toFixed(2)} km (${distMi.toFixed(2)} mi)`
|
||||
: "—"
|
||||
}
|
||||
<b data-translate-lang="hops">Hops</b>: ${hopKey}
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><a href="/node/${s.node_id}">${label}</a></td>
|
||||
<td>${s.rx_rssi ?? "—"}</td>
|
||||
<td>${s.rx_snr ?? "—"}</td>
|
||||
<td>${hopKey}</td>
|
||||
<td>${s.channel ?? "—"}</td>
|
||||
<td>${timeStr}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><a href="/node/${s.node_id}">${label}</a></td>
|
||||
<td>${s.rx_rssi ?? "—"}</td>
|
||||
<td>${s.rx_snr ?? "—"}</td>
|
||||
<td>${s.hop_start ?? "—"} → ${s.hop_limit ?? "—"}</td>
|
||||
<td>${s.channel ?? "—"}</td>
|
||||
<td>${timeStr}</td>
|
||||
</tr>`;
|
||||
<td colspan="6"
|
||||
style="
|
||||
background:#1f2327;
|
||||
font-weight:700;
|
||||
color:#9ecbff;
|
||||
border-top:1px solid #444;
|
||||
padding:8px 12px;
|
||||
">
|
||||
🔁 ${hopLabel} (${hopGroups[hopKey].length})
|
||||
</td>
|
||||
</tr>
|
||||
${rows}
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
|
||||
/* ---------------------------------------------
|
||||
Fit map around all markers
|
||||
----------------------------------------------*/
|
||||
|
||||
@@ -39,7 +39,8 @@
|
||||
}
|
||||
|
||||
table th { background-color: #333; }
|
||||
table tbody tr:nth-child(odd) { background-color: #272b2f; }
|
||||
|
||||
table tbody tr:nth-child(odd) { background-color: #272b2f; }
|
||||
table tbody tr:nth-child(even) { background-color: #212529; }
|
||||
table tbody tr:hover { background-color: #555; cursor: pointer; }
|
||||
|
||||
@@ -50,8 +51,15 @@
|
||||
.node-link:hover { text-decoration: underline; }
|
||||
|
||||
.good-x { color: #81ff81; font-weight: bold; }
|
||||
.ok-x { color: #e8e86d; font-weight: bold; }
|
||||
.bad-x { color: #ff6464; font-weight: bold; }
|
||||
.ok-x { color: #e8e86d; font-weight: bold; }
|
||||
.bad-x { color: #ff6464; font-weight: bold; }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -63,47 +71,42 @@
|
||||
|
||||
<div class="filter-bar">
|
||||
<div>
|
||||
<label for="channelFilter" data-translate-lang="channel">Channel:</label>
|
||||
<select id="channelFilter" class="form-select form-select-sm" style="width:auto;"></select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="nodeSearch" data-translate-lang="search">Search:</label>
|
||||
<input id="nodeSearch" type="text" class="form-control form-control-sm"
|
||||
placeholder="Search nodes..."
|
||||
data-translate-lang="search_placeholder"
|
||||
style="width:180px; display:inline-block;">
|
||||
<label data-translate-lang="channel">Channel:</label>
|
||||
<select id="channelFilter"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ⭐ ADDED NODE COUNT ⭐ -->
|
||||
<div id="count-container" style="margin-bottom:10px; font-weight:bold;">
|
||||
<div style="margin-bottom:10px;font-weight:bold;">
|
||||
<span data-translate-lang="showing_nodes">Showing</span>
|
||||
<span id="node-count">0</span>
|
||||
<span data-translate-lang="nodes_suffix">nodes</span>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table id="nodesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="long_name">Long Name</th>
|
||||
<th data-translate-lang="short_name">Short Name</th>
|
||||
<th data-translate-lang="channel">Channel</th>
|
||||
<th data-translate-lang="packets_sent">Sent (24h)</th>
|
||||
<th data-translate-lang="times_seen">Seen (24h)</th>
|
||||
<th data-translate-lang="avg_gateways">Avg Gateways</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<table id="nodesTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="long_name">Long Name</th>
|
||||
<th data-translate-lang="short_name">Short Name</th>
|
||||
<th data-translate-lang="channel">Channel</th>
|
||||
<th data-translate-lang="packets_sent">Sent (24h)</th>
|
||||
<th data-translate-lang="times_seen">Seen (24h)</th>
|
||||
<th data-translate-lang="avg_gateways">Avg Gateways</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
<button id="prevPage" class="btn btn-sm btn-secondary">Prev</button>
|
||||
<span id="pageInfo"></span>
|
||||
<button id="nextPage" class="btn btn-sm btn-secondary">Next</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ======================================================
|
||||
TOP PAGE TRANSLATION (isolated from base)
|
||||
TRANSLATIONS
|
||||
====================================================== */
|
||||
let topTranslations = {};
|
||||
|
||||
@@ -111,198 +114,127 @@ function applyTranslationsTop(dict, root=document) {
|
||||
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if (!dict[key]) return;
|
||||
|
||||
// input placeholder support
|
||||
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
|
||||
el.placeholder = dict[key];
|
||||
} else {
|
||||
el.textContent = dict[key];
|
||||
}
|
||||
el.textContent = dict[key];
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTranslationsTop() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const lang = cfg?.site?.language || "en";
|
||||
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=top`);
|
||||
topTranslations = await res.json();
|
||||
|
||||
applyTranslationsTop(topTranslations);
|
||||
} catch (err) {
|
||||
console.error("TOP translation load failed:", err);
|
||||
}
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const lang = cfg?.site?.language || "en";
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=top`);
|
||||
topTranslations = await res.json();
|
||||
applyTranslationsTop(topTranslations);
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
PAGE LOGIC
|
||||
CONFIG
|
||||
====================================================== */
|
||||
let allNodes = [];
|
||||
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const res = await fetch("/api/channels");
|
||||
const data = await res.json();
|
||||
const channels = data.channels || [];
|
||||
|
||||
const select = document.getElementById("channelFilter");
|
||||
|
||||
// LongFast first
|
||||
if (channels.includes("LongFast")) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "LongFast";
|
||||
opt.textContent = "LongFast";
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
for (const ch of channels) {
|
||||
if (ch === "LongFast") continue;
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ch;
|
||||
opt.textContent = ch;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
select.addEventListener("change", renderTable);
|
||||
} catch (err) {
|
||||
console.error("Error loading channels:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
const res = await fetch("/api/nodes");
|
||||
const data = await res.json();
|
||||
allNodes = data.nodes || [];
|
||||
} catch (err) {
|
||||
console.error("Error loading nodes:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNodeStats(nodeId) {
|
||||
try {
|
||||
const res = await fetch(`/api/stats/count?from_node=${nodeId}&period_type=day&length=1`);
|
||||
const data = await res.json();
|
||||
|
||||
const sent = data.total_packets || 0;
|
||||
const seen = data.total_seen || 0;
|
||||
const avg = seen / Math.max(sent, 1);
|
||||
|
||||
return { sent, seen, avg };
|
||||
} catch (err) {
|
||||
console.error("Stat error:", err);
|
||||
return { sent: 0, seen: 0, avg: 0 };
|
||||
}
|
||||
}
|
||||
const PAGE_SIZE = 20;
|
||||
let currentPage = 0;
|
||||
let totalRows = 0;
|
||||
|
||||
/* ======================================================
|
||||
HELPERS
|
||||
====================================================== */
|
||||
function avgClass(v) {
|
||||
if (v >= 10) return "good-x";
|
||||
if (v >= 2) return "ok-x";
|
||||
if (v >= 2) return "ok-x";
|
||||
return "bad-x";
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
LOAD CHANNELS
|
||||
====================================================== */
|
||||
async function loadChannels() {
|
||||
const res = await fetch("/api/channels");
|
||||
const data = await res.json();
|
||||
const sel = document.getElementById("channelFilter");
|
||||
|
||||
sel.innerHTML = "";
|
||||
for (const ch of data.channels || []) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ch;
|
||||
opt.textContent = ch;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
sel.value = "MediumFast";
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
FETCH + RENDER
|
||||
====================================================== */
|
||||
async function renderTable() {
|
||||
const tbody = document.querySelector("#nodesTable tbody");
|
||||
tbody.innerHTML = "";
|
||||
|
||||
const channel = document.getElementById("channelFilter").value;
|
||||
const searchText = document.getElementById("nodeSearch").value.trim().toLowerCase();
|
||||
const offset = currentPage * PAGE_SIZE;
|
||||
|
||||
// Filter by channel
|
||||
let filtered = allNodes.filter(n => n.channel === channel);
|
||||
const url = new URL("/api/stats/top", window.location.origin);
|
||||
url.searchParams.set("limit", PAGE_SIZE);
|
||||
url.searchParams.set("offset", offset);
|
||||
if (channel) url.searchParams.set("channel", channel);
|
||||
|
||||
// Filter by search
|
||||
if (searchText !== "") {
|
||||
filtered = filtered.filter(n =>
|
||||
(n.long_name && n.long_name.toLowerCase().includes(searchText)) ||
|
||||
(n.short_name && n.short_name.toLowerCase().includes(searchText)) ||
|
||||
String(n.node_id).includes(searchText)
|
||||
);
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
totalRows = data.total || 0;
|
||||
|
||||
for (const n of data.nodes || []) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.onclick = () => location.href = `/node/${n.node_id}`;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<a class="node-link" href="/node/${n.node_id}"
|
||||
onclick="event.stopPropagation()">
|
||||
${n.long_name || n.node_id}
|
||||
</a>
|
||||
</td>
|
||||
<td>${n.short_name || ""}</td>
|
||||
<td>${n.channel || ""}</td>
|
||||
<td>${n.sent}</td>
|
||||
<td>${n.seen}</td>
|
||||
<td><span class="${avgClass(n.avg)}">${n.avg.toFixed(1)}</span></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
// Placeholder rows first
|
||||
const rowRefs = filtered.map(n => {
|
||||
const tr = document.createElement("tr");
|
||||
tr.addEventListener("click", () => window.location.href = `/node/${n.node_id}`);
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
||||
|
||||
const tdLong = document.createElement("td");
|
||||
const a = document.createElement("a");
|
||||
a.href = `/node/${n.node_id}`;
|
||||
a.textContent = n.long_name || n.node_id;
|
||||
a.className = "node-link";
|
||||
a.addEventListener("click", e => e.stopPropagation());
|
||||
tdLong.appendChild(a);
|
||||
document.getElementById("node-count").textContent = totalRows;
|
||||
document.getElementById("pageInfo").textContent =
|
||||
`Page ${currentPage + 1} / ${totalPages}`;
|
||||
|
||||
const tdShort = document.createElement("td");
|
||||
tdShort.textContent = n.short_name || "";
|
||||
|
||||
const tdChannel = document.createElement("td");
|
||||
tdChannel.textContent = n.channel || "";
|
||||
|
||||
const tdSent = document.createElement("td");
|
||||
tdSent.textContent = "...";
|
||||
|
||||
const tdSeen = document.createElement("td");
|
||||
tdSeen.textContent = "...";
|
||||
|
||||
const tdAvg = document.createElement("td");
|
||||
tdAvg.textContent = "...";
|
||||
|
||||
tr.appendChild(tdLong);
|
||||
tr.appendChild(tdShort);
|
||||
tr.appendChild(tdChannel);
|
||||
tr.appendChild(tdSent);
|
||||
tr.appendChild(tdSeen);
|
||||
tr.appendChild(tdAvg);
|
||||
|
||||
tbody.appendChild(tr);
|
||||
|
||||
return { node: n, tr, tdSent, tdSeen, tdAvg };
|
||||
});
|
||||
|
||||
// Fetch stats
|
||||
const statsList = await Promise.all(
|
||||
rowRefs.map(ref => fetchNodeStats(ref.node.node_id))
|
||||
);
|
||||
|
||||
// Update rows
|
||||
let combined = rowRefs.map((ref, i) => {
|
||||
const stats = statsList[i];
|
||||
|
||||
ref.tdSent.textContent = stats.sent;
|
||||
ref.tdSeen.textContent = stats.seen;
|
||||
ref.tdAvg.innerHTML =
|
||||
`<span class="${avgClass(stats.avg)}">${stats.avg.toFixed(1)}</span>`;
|
||||
|
||||
return { tr: ref.tr, sent: stats.sent, seen: stats.seen };
|
||||
});
|
||||
|
||||
// Remove nodes with no activity
|
||||
combined = combined.filter(r => !(r.sent === 0 && r.seen === 0));
|
||||
|
||||
// Sort by seen
|
||||
combined.sort((a, b) => b.seen - a.seen);
|
||||
|
||||
tbody.innerHTML = "";
|
||||
for (const r of combined) tbody.appendChild(r.tr);
|
||||
|
||||
// ⭐ UPDATE COUNT ⭐
|
||||
document.getElementById("node-count").textContent = combined.length;
|
||||
document.getElementById("prevPage").disabled = currentPage === 0;
|
||||
document.getElementById("nextPage").disabled = currentPage >= totalPages - 1;
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
INITIALIZE PAGE
|
||||
INIT
|
||||
====================================================== */
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadTranslationsTop(); // ⭐ MUST run first
|
||||
await loadNodes();
|
||||
await loadTranslationsTop();
|
||||
await loadChannels();
|
||||
await renderTable();
|
||||
|
||||
document.getElementById("channelFilter").value = "LongFast";
|
||||
document.getElementById("nodeSearch").addEventListener("input", renderTable);
|
||||
channelFilter.onchange = () => {
|
||||
currentPage = 0;
|
||||
renderTable();
|
||||
};
|
||||
|
||||
renderTable();
|
||||
prevPage.onclick = () => {
|
||||
if (currentPage > 0) {
|
||||
currentPage--;
|
||||
renderTable();
|
||||
}
|
||||
};
|
||||
|
||||
nextPage.onclick = () => {
|
||||
currentPage++;
|
||||
renderTable();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Main web server routes and page rendering for Meshview."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import ssl
|
||||
from dataclasses import dataclass
|
||||
@@ -12,7 +15,7 @@ from google.protobuf import text_format
|
||||
from google.protobuf.message import Message
|
||||
from jinja2 import Environment, PackageLoader, Undefined, select_autoescape
|
||||
from markupsafe import Markup
|
||||
import pathlib
|
||||
|
||||
from meshtastic.protobuf.portnums_pb2 import PortNum
|
||||
from meshview import config, database, decode_payload, migrations, models, store
|
||||
from meshview.__version__ import (
|
||||
@@ -45,6 +48,8 @@ with open(os.path.join(os.path.dirname(__file__), '1x1.png'), 'rb') as png:
|
||||
|
||||
@dataclass
|
||||
class Packet:
|
||||
"""UI-friendly packet wrapper for templates and API payloads."""
|
||||
|
||||
id: int
|
||||
from_node_id: int
|
||||
from_node: models.Node
|
||||
@@ -56,11 +61,11 @@ class Packet:
|
||||
raw_payload: object
|
||||
payload: str
|
||||
pretty_payload: Markup
|
||||
import_time: datetime.datetime
|
||||
import_time_us: int
|
||||
|
||||
@classmethod
|
||||
def from_model(cls, packet):
|
||||
"""Convert a Packet ORM model into a presentation-friendly Packet."""
|
||||
mesh_packet, payload = decode_payload.decode(packet)
|
||||
pretty_payload = None
|
||||
|
||||
@@ -101,7 +106,6 @@ class Packet:
|
||||
data=text_mesh_packet,
|
||||
payload=text_payload, # now always a string
|
||||
pretty_payload=pretty_payload,
|
||||
import_time=packet.import_time,
|
||||
import_time_us=packet.import_time_us, # <-- include microseconds
|
||||
raw_mesh_packet=mesh_packet,
|
||||
raw_payload=payload,
|
||||
@@ -109,6 +113,7 @@ class Packet:
|
||||
|
||||
|
||||
async def build_trace(node_id):
|
||||
"""Build a recent GPS trace list for a node using position packets."""
|
||||
trace = []
|
||||
for raw_p in await store.get_packets_from(
|
||||
node_id, PortNum.POSITION_APP, since=datetime.timedelta(hours=24)
|
||||
@@ -130,6 +135,7 @@ async def build_trace(node_id):
|
||||
|
||||
|
||||
async def build_neighbors(node_id):
|
||||
"""Return neighbor node metadata for the given node ID."""
|
||||
packets = await store.get_packets_from(node_id, PortNum.NEIGHBORINFO_APP, limit=1)
|
||||
packet = packets.first()
|
||||
|
||||
@@ -159,6 +165,7 @@ async def build_neighbors(node_id):
|
||||
|
||||
|
||||
def node_id_to_hex(node_id):
|
||||
"""Format a node_id in Meshtastic hex notation."""
|
||||
if node_id is None or isinstance(node_id, Undefined):
|
||||
return "Invalid node_id" # i... have no clue
|
||||
if node_id == 4294967295:
|
||||
@@ -168,6 +175,7 @@ def node_id_to_hex(node_id):
|
||||
|
||||
|
||||
def format_timestamp(timestamp):
|
||||
"""Normalize timestamps to ISO 8601 strings."""
|
||||
if isinstance(timestamp, int):
|
||||
timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.UTC)
|
||||
return timestamp.isoformat(timespec="milliseconds")
|
||||
@@ -185,6 +193,7 @@ routes = web.RouteTableDef()
|
||||
|
||||
@routes.get("/")
|
||||
async def index(request):
|
||||
"""Redirect root URL to configured starting page."""
|
||||
"""
|
||||
Redirect root URL '/' to the page specified in CONFIG['site']['starting'].
|
||||
Defaults to '/map' if not set.
|
||||
@@ -203,6 +212,7 @@ async def redirect_packet_list(request):
|
||||
# Generic static HTML route
|
||||
@routes.get("/{page}")
|
||||
async def serve_page(request):
|
||||
"""Serve static HTML pages from meshview/static."""
|
||||
page = request.match_info["page"]
|
||||
|
||||
# default to index.html if no extension
|
||||
@@ -217,7 +227,6 @@ async def serve_page(request):
|
||||
return web.Response(text=content, content_type="text/html")
|
||||
|
||||
|
||||
|
||||
@routes.get("/net")
|
||||
async def net(request):
|
||||
return web.Response(
|
||||
@@ -352,8 +361,8 @@ async def graph_traceroute(request):
|
||||
# It seems some nodes add them self to the list before uplinking
|
||||
path.append(tr.gateway_node_id)
|
||||
|
||||
if not tr.done and tr.gateway_node_id not in node_seen_time and tr.import_time:
|
||||
node_seen_time[path[-1]] = tr.import_time
|
||||
if not tr.done and tr.gateway_node_id not in node_seen_time and tr.import_time_us:
|
||||
node_seen_time[path[-1]] = tr.import_time_us
|
||||
|
||||
mqtt_nodes.add(tr.gateway_node_id)
|
||||
node_color[path[-1]] = '#' + hex(hash(tuple(path)))[3:9]
|
||||
@@ -363,7 +372,7 @@ async def graph_traceroute(request):
|
||||
for path in paths:
|
||||
used_nodes.update(path)
|
||||
|
||||
import_times = [tr.import_time for tr in traceroutes if tr.import_time]
|
||||
import_times = [tr.import_time_us for tr in traceroutes if tr.import_time_us]
|
||||
if import_times:
|
||||
first_time = min(import_times)
|
||||
else:
|
||||
@@ -378,7 +387,7 @@ async def graph_traceroute(request):
|
||||
f'[{node.short_name}] {node.long_name}\n{node_id_to_hex(node_id)}\n{node.role}'
|
||||
)
|
||||
if node_id in node_seen_time:
|
||||
ms = (node_seen_time[node_id] - first_time).total_seconds() * 1000
|
||||
ms = (node_seen_time[node_id] - first_time) / 1000
|
||||
node_name += f'\n {ms:.2f}ms'
|
||||
style = 'dashed'
|
||||
if node_id == dest:
|
||||
@@ -396,7 +405,7 @@ async def graph_traceroute(request):
|
||||
shape='box',
|
||||
color=node_color.get(node_id, 'black'),
|
||||
style=style,
|
||||
href=f"/packet_list/{node_id}",
|
||||
href=f"/node/{node_id}",
|
||||
)
|
||||
)
|
||||
|
||||
@@ -412,6 +421,7 @@ async def graph_traceroute(request):
|
||||
|
||||
|
||||
async def run_server():
|
||||
"""Start the aiohttp web server after migrations are complete."""
|
||||
# Wait for database migrations to complete before starting web server
|
||||
logger.info("Checking database schema status...")
|
||||
database_url = CONFIG["database"]["connection_string"]
|
||||
@@ -428,6 +438,7 @@ async def run_server():
|
||||
logger.info("Database schema verified - starting web server")
|
||||
|
||||
app = web.Application()
|
||||
app.router.add_static("/static/", pathlib.Path(__file__).parent / "static")
|
||||
app.add_routes(api.routes) # Add API routes
|
||||
app.add_routes(routes) # Add main web routes
|
||||
|
||||
|
||||
@@ -6,12 +6,15 @@ import logging
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy import func, select, text
|
||||
|
||||
from meshtastic.protobuf.portnums_pb2 import PortNum
|
||||
from meshview import database, decode_payload, store
|
||||
from meshview.__version__ import __version__, _git_revision_short, get_version_info
|
||||
from meshview.config import CONFIG
|
||||
from meshview.models import Node
|
||||
from meshview.models import Packet as PacketModel
|
||||
from meshview.models import PacketSeen as PacketSeenModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,7 +129,6 @@ async def api_packets(request):
|
||||
"portnum": int(p.portnum) if p.portnum is not None else None,
|
||||
"payload": (p.payload or "").strip(),
|
||||
"import_time_us": p.import_time_us,
|
||||
"import_time": p.import_time.isoformat() if p.import_time else None,
|
||||
"channel": getattr(p.from_node, "channel", ""),
|
||||
"long_name": getattr(p.from_node, "long_name", ""),
|
||||
}
|
||||
@@ -134,7 +136,7 @@ async def api_packets(request):
|
||||
|
||||
# --- Parse limit ---
|
||||
try:
|
||||
limit = min(max(int(limit_str), 1), 100)
|
||||
limit = min(max(int(limit_str), 1), 1000)
|
||||
except ValueError:
|
||||
limit = 50
|
||||
|
||||
@@ -178,13 +180,17 @@ async def api_packets(request):
|
||||
logger.warning(f"Invalid node_id: {node_id_str}")
|
||||
|
||||
# --- Fetch packets using explicit filters ---
|
||||
contains_for_query = contains
|
||||
if portnum == PortNum.TEXT_MESSAGE_APP and contains:
|
||||
contains_for_query = None
|
||||
|
||||
packets = await store.get_packets(
|
||||
from_node_id=from_node_id,
|
||||
to_node_id=to_node_id,
|
||||
node_id=node_id,
|
||||
portnum=portnum,
|
||||
after=since,
|
||||
contains=contains,
|
||||
contains=contains_for_query,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
@@ -208,7 +214,6 @@ async def api_packets(request):
|
||||
packet_dict = {
|
||||
"id": p.id,
|
||||
"import_time_us": p.import_time_us,
|
||||
"import_time": p.import_time.isoformat() if p.import_time else None,
|
||||
"channel": getattr(p.from_node, "channel", ""),
|
||||
"from_node_id": p.from_node_id,
|
||||
"to_node_id": p.to_node_id,
|
||||
@@ -228,20 +233,12 @@ async def api_packets(request):
|
||||
|
||||
packets_data.append(packet_dict)
|
||||
|
||||
# --- Latest import_time for incremental fetch ---
|
||||
# --- Latest import_time_us for incremental fetch ---
|
||||
latest_import_time = None
|
||||
if packets_data:
|
||||
for p in packets_data:
|
||||
if p.get("import_time_us") and p["import_time_us"] > 0:
|
||||
latest_import_time = max(latest_import_time or 0, p["import_time_us"])
|
||||
elif p.get("import_time") and latest_import_time is None:
|
||||
try:
|
||||
dt = datetime.datetime.fromisoformat(
|
||||
p["import_time"].replace("Z", "+00:00")
|
||||
)
|
||||
latest_import_time = int(dt.timestamp() * 1_000_000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
response = {"packets": packets_data}
|
||||
if latest_import_time is not None:
|
||||
@@ -421,7 +418,7 @@ async def api_stats_count(request):
|
||||
|
||||
@routes.get("/api/edges")
|
||||
async def api_edges(request):
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=48)
|
||||
since = datetime.datetime.now() - datetime.timedelta(hours=12)
|
||||
filter_type = request.query.get("type")
|
||||
|
||||
# NEW → optional single-node filter
|
||||
@@ -431,14 +428,10 @@ async def api_edges(request):
|
||||
try:
|
||||
node_filter = int(node_filter_str)
|
||||
except ValueError:
|
||||
return web.json_response(
|
||||
{"error": "node_id must be integer"},
|
||||
status=400
|
||||
)
|
||||
return web.json_response({"error": "node_id must be integer"}, status=400)
|
||||
|
||||
edges = {}
|
||||
traceroute_count = 0
|
||||
neighbor_packet_count = 0
|
||||
edges_added_tr = 0
|
||||
edges_added_neighbor = 0
|
||||
|
||||
@@ -463,8 +456,6 @@ async def api_edges(request):
|
||||
# --- Neighbor edges ---
|
||||
if filter_type in (None, "neighbor"):
|
||||
packets = await store.get_packets(portnum=71)
|
||||
neighbor_packet_count = len(packets)
|
||||
|
||||
for packet in packets:
|
||||
try:
|
||||
_, neighbor_info = decode_payload.decode(packet)
|
||||
@@ -479,21 +470,16 @@ async def api_edges(request):
|
||||
|
||||
# Convert to list
|
||||
edges_list = [
|
||||
{"from": frm, "to": to, "type": edge_type}
|
||||
for (frm, to), edge_type in edges.items()
|
||||
{"from": frm, "to": to, "type": edge_type} for (frm, to), edge_type in edges.items()
|
||||
]
|
||||
|
||||
# NEW → apply node_id filtering
|
||||
if node_filter is not None:
|
||||
edges_list = [
|
||||
e for e in edges_list
|
||||
if e["from"] == node_filter or e["to"] == node_filter
|
||||
]
|
||||
edges_list = [e for e in edges_list if e["from"] == node_filter or e["to"] == node_filter]
|
||||
|
||||
return web.json_response({"edges": edges_list})
|
||||
|
||||
|
||||
|
||||
@routes.get("/api/config")
|
||||
async def api_config(request):
|
||||
try:
|
||||
@@ -711,7 +697,6 @@ async def api_packets_seen(request):
|
||||
"rx_snr": row.rx_snr,
|
||||
"rx_rssi": row.rx_rssi,
|
||||
"topic": row.topic,
|
||||
"import_time": (row.import_time.isoformat() if row.import_time else None),
|
||||
"import_time_us": row.import_time_us,
|
||||
}
|
||||
)
|
||||
@@ -724,3 +709,178 @@ async def api_packets_seen(request):
|
||||
{"error": "Internal server error"},
|
||||
status=500,
|
||||
)
|
||||
|
||||
|
||||
@routes.get("/api/traceroute/{packet_id}")
|
||||
async def api_traceroute(request):
|
||||
packet_id = int(request.match_info['packet_id'])
|
||||
|
||||
traceroutes = list(await store.get_traceroute(packet_id))
|
||||
packet = await store.get_packet(packet_id)
|
||||
|
||||
if not packet:
|
||||
return web.json_response({"error": "Packet not found"}, status=404)
|
||||
|
||||
tr_groups = []
|
||||
|
||||
# --------------------------------------------
|
||||
# Decode each traceroute entry
|
||||
# --------------------------------------------
|
||||
for idx, tr in enumerate(traceroutes):
|
||||
route = decode_payload.decode_payload(PortNum.TRACEROUTE_APP, tr.route)
|
||||
|
||||
forward_list = list(route.route)
|
||||
reverse_list = list(route.route_back)
|
||||
|
||||
tr_groups.append(
|
||||
{
|
||||
"index": idx,
|
||||
"gateway_node_id": tr.gateway_node_id,
|
||||
"done": tr.done,
|
||||
"forward_hops": forward_list,
|
||||
"reverse_hops": reverse_list,
|
||||
}
|
||||
)
|
||||
|
||||
# --------------------------------------------
|
||||
# Compute UNIQUE paths + counts + winning path
|
||||
# --------------------------------------------
|
||||
from collections import Counter
|
||||
|
||||
forward_paths = []
|
||||
reverse_paths = []
|
||||
winning_paths = []
|
||||
|
||||
for tr in tr_groups:
|
||||
f = tuple(tr["forward_hops"])
|
||||
r = tuple(tr["reverse_hops"])
|
||||
|
||||
if tr["forward_hops"]:
|
||||
forward_paths.append(f)
|
||||
|
||||
if tr["reverse_hops"]:
|
||||
reverse_paths.append(r)
|
||||
|
||||
if tr["done"]:
|
||||
winning_paths.append(f)
|
||||
|
||||
# Deduplicate
|
||||
unique_forward_paths = sorted(set(forward_paths))
|
||||
unique_reverse_paths = sorted(set(reverse_paths))
|
||||
|
||||
# Count occurrences
|
||||
forward_counts = Counter(forward_paths)
|
||||
|
||||
# Convert for JSON output
|
||||
unique_forward_paths_json = [
|
||||
{"path": list(p), "count": forward_counts[p]} for p in unique_forward_paths
|
||||
]
|
||||
|
||||
unique_reverse_paths_json = [list(p) for p in unique_reverse_paths]
|
||||
|
||||
winning_paths_json = [list(p) for p in set(winning_paths)]
|
||||
|
||||
# --------------------------------------------
|
||||
# Final API output
|
||||
# --------------------------------------------
|
||||
return web.json_response(
|
||||
{
|
||||
"packet": {
|
||||
"id": packet.id,
|
||||
"from": packet.from_node_id,
|
||||
"to": packet.to_node_id,
|
||||
"channel": packet.channel,
|
||||
},
|
||||
"traceroute_packets": tr_groups,
|
||||
"unique_forward_paths": unique_forward_paths_json,
|
||||
"unique_reverse_paths": unique_reverse_paths_json,
|
||||
"winning_paths": winning_paths_json,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@routes.get("/api/stats/top")
|
||||
async def api_stats_top(request):
|
||||
"""
|
||||
Returns nodes sorted by SEEN (high → low) with pagination.
|
||||
"""
|
||||
|
||||
period_type = request.query.get("period_type", "day")
|
||||
length = int(request.query.get("length", 1))
|
||||
channel = request.query.get("channel")
|
||||
|
||||
limit = min(int(request.query.get("limit", 20)), 100)
|
||||
offset = int(request.query.get("offset", 0))
|
||||
|
||||
multiplier = 3600 if period_type == "hour" else 86400
|
||||
window_us = length * multiplier * 1_000_000
|
||||
|
||||
max_packet_import = select(func.max(PacketModel.import_time_us)).scalar_subquery()
|
||||
max_seen_import = select(func.max(PacketSeenModel.import_time_us)).scalar_subquery()
|
||||
|
||||
sent_cte = (
|
||||
select(PacketModel.from_node_id.label("node_id"), func.count().label("sent"))
|
||||
.where(PacketModel.import_time_us >= max_packet_import - window_us)
|
||||
.group_by(PacketModel.from_node_id)
|
||||
.cte("sent")
|
||||
)
|
||||
|
||||
seen_cte = (
|
||||
select(PacketModel.from_node_id.label("node_id"), func.count().label("seen"))
|
||||
.select_from(PacketSeenModel)
|
||||
.join(PacketModel, PacketModel.id == PacketSeenModel.packet_id)
|
||||
.where(PacketSeenModel.import_time_us >= max_seen_import - window_us)
|
||||
.group_by(PacketModel.from_node_id)
|
||||
.cte("seen")
|
||||
)
|
||||
|
||||
query = (
|
||||
select(
|
||||
Node.node_id,
|
||||
Node.long_name,
|
||||
Node.short_name,
|
||||
Node.channel,
|
||||
func.coalesce(sent_cte.c.sent, 0).label("sent"),
|
||||
func.coalesce(seen_cte.c.seen, 0).label("seen"),
|
||||
)
|
||||
.select_from(Node)
|
||||
.outerjoin(sent_cte, sent_cte.c.node_id == Node.node_id)
|
||||
.outerjoin(seen_cte, seen_cte.c.node_id == Node.node_id)
|
||||
.order_by(func.coalesce(seen_cte.c.seen, 0).desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
count_query = select(func.count()).select_from(Node)
|
||||
|
||||
if channel:
|
||||
query = query.where(Node.channel == channel)
|
||||
count_query = count_query.where(Node.channel == channel)
|
||||
|
||||
async with database.async_session() as session:
|
||||
rows = (await session.execute(query)).all()
|
||||
total = (await session.execute(count_query)).scalar() or 0
|
||||
|
||||
nodes = []
|
||||
for r in rows:
|
||||
avg = r.seen / max(r.sent, 1)
|
||||
nodes.append(
|
||||
{
|
||||
"node_id": r.node_id,
|
||||
"long_name": r.long_name,
|
||||
"short_name": r.short_name,
|
||||
"channel": r.channel,
|
||||
"sent": r.sent,
|
||||
"seen": r.seen,
|
||||
"avg": round(avg, 2),
|
||||
}
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"nodes": nodes,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -76,12 +76,21 @@ port = 1883
|
||||
username = meshdev
|
||||
password = large4cats
|
||||
|
||||
# Optional list of node IDs to ignore. Comma-separated.
|
||||
skip_node_ids =
|
||||
|
||||
# Optional list of secondary AES keys (base64), comma-separated.
|
||||
secondary_keys =
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Database Configuration
|
||||
# -------------------------
|
||||
[database]
|
||||
# SQLAlchemy connection string. This one uses SQLite with asyncio support.
|
||||
# SQLAlchemy async connection string.
|
||||
# Examples:
|
||||
# sqlite+aiosqlite:///packets.db
|
||||
# postgresql+asyncpg://user:pass@host:5432/meshview
|
||||
connection_string = sqlite+aiosqlite:///packets.db
|
||||
|
||||
|
||||
|
||||
37
startdb.py
37
startdb.py
@@ -7,6 +7,7 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import delete
|
||||
from sqlalchemy.engine.url import make_url
|
||||
|
||||
from meshview import migrations, models, mqtt_database, mqtt_reader, mqtt_store
|
||||
from meshview.config import CONFIG
|
||||
@@ -65,18 +66,16 @@ async def backup_database(database_url: str, backup_dir: str = ".") -> None:
|
||||
backup_dir: Directory to store backups (default: current directory)
|
||||
"""
|
||||
try:
|
||||
# Extract database file path from connection string
|
||||
# Format: sqlite+aiosqlite:///path/to/db.db
|
||||
if not database_url.startswith("sqlite"):
|
||||
url = make_url(database_url)
|
||||
if not url.drivername.startswith("sqlite"):
|
||||
cleanup_logger.warning("Backup only supported for SQLite databases")
|
||||
return
|
||||
|
||||
db_path = database_url.split("///", 1)[1] if "///" in database_url else None
|
||||
if not db_path:
|
||||
if not url.database or url.database == ":memory:":
|
||||
cleanup_logger.error("Could not extract database path from connection string")
|
||||
return
|
||||
|
||||
db_file = Path(db_path)
|
||||
db_file = Path(url.database)
|
||||
if not db_file.exists():
|
||||
cleanup_logger.error(f"Database file not found: {db_file}")
|
||||
return
|
||||
@@ -153,11 +152,11 @@ async def daily_cleanup_at(
|
||||
cleanup_logger.info("Waiting 60 seconds for backup to complete...")
|
||||
await asyncio.sleep(60)
|
||||
|
||||
# Local-time cutoff as string for SQLite DATETIME comparison
|
||||
cutoff = (datetime.datetime.now() - datetime.timedelta(days=days_to_keep)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
cleanup_logger.info(f"Running cleanup for records older than {cutoff}...")
|
||||
cutoff_dt = (
|
||||
datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_to_keep)
|
||||
).replace(tzinfo=None)
|
||||
cutoff_us = int(cutoff_dt.timestamp() * 1_000_000)
|
||||
cleanup_logger.info(f"Running cleanup for records older than {cutoff_dt.isoformat()}...")
|
||||
|
||||
try:
|
||||
async with db_lock: # Pause ingestion
|
||||
@@ -168,7 +167,7 @@ async def daily_cleanup_at(
|
||||
# Packet
|
||||
# -------------------------
|
||||
result = await session.execute(
|
||||
delete(models.Packet).where(models.Packet.import_time < cutoff)
|
||||
delete(models.Packet).where(models.Packet.import_time_us < cutoff_us)
|
||||
)
|
||||
cleanup_logger.info(f"Deleted {result.rowcount} rows from Packet")
|
||||
|
||||
@@ -176,7 +175,9 @@ async def daily_cleanup_at(
|
||||
# PacketSeen
|
||||
# -------------------------
|
||||
result = await session.execute(
|
||||
delete(models.PacketSeen).where(models.PacketSeen.import_time < cutoff)
|
||||
delete(models.PacketSeen).where(
|
||||
models.PacketSeen.import_time_us < cutoff_us
|
||||
)
|
||||
)
|
||||
cleanup_logger.info(f"Deleted {result.rowcount} rows from PacketSeen")
|
||||
|
||||
@@ -184,7 +185,9 @@ async def daily_cleanup_at(
|
||||
# Traceroute
|
||||
# -------------------------
|
||||
result = await session.execute(
|
||||
delete(models.Traceroute).where(models.Traceroute.import_time < cutoff)
|
||||
delete(models.Traceroute).where(
|
||||
models.Traceroute.import_time_us < cutoff_us
|
||||
)
|
||||
)
|
||||
cleanup_logger.info(f"Deleted {result.rowcount} rows from Traceroute")
|
||||
|
||||
@@ -192,17 +195,19 @@ async def daily_cleanup_at(
|
||||
# Node
|
||||
# -------------------------
|
||||
result = await session.execute(
|
||||
delete(models.Node).where(models.Node.last_update < cutoff)
|
||||
delete(models.Node).where(models.Node.last_seen_us < cutoff_us)
|
||||
)
|
||||
cleanup_logger.info(f"Deleted {result.rowcount} rows from Node")
|
||||
|
||||
await session.commit()
|
||||
|
||||
if vacuum_db:
|
||||
if vacuum_db and mqtt_database.engine.dialect.name == "sqlite":
|
||||
cleanup_logger.info("Running VACUUM...")
|
||||
async with mqtt_database.engine.begin() as conn:
|
||||
await conn.exec_driver_sql("VACUUM;")
|
||||
cleanup_logger.info("VACUUM completed.")
|
||||
elif vacuum_db:
|
||||
cleanup_logger.info("VACUUM skipped (not supported for this database).")
|
||||
|
||||
cleanup_logger.info("Cleanup completed successfully.")
|
||||
cleanup_logger.info("Ingestion resumed after cleanup.")
|
||||
|
||||
Reference in New Issue
Block a user