1 Commits

Author SHA1 Message Date
pablorevilla-meshtastic a676db2921 Initial Jinja ESP32 collector 2026-03-18 14:28:04 -07:00
31 changed files with 1249 additions and 1277 deletions
-1
View File
@@ -45,4 +45,3 @@ __pycache__/
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
packets.db-journal
-4
View File
@@ -128,10 +128,6 @@ username =
password = password =
[database] [database]
# SQLAlchemy async connection string.
# Examples:
# sqlite+aiosqlite:///var/lib/meshview/packets.db
# postgresql+asyncpg://user:pass@host:5432/meshview
connection_string = sqlite+aiosqlite:///var/lib/meshview/packets.db connection_string = sqlite+aiosqlite:///var/lib/meshview/packets.db
``` ```
+5 -32
View File
@@ -272,24 +272,9 @@ password = large4cats
# Database Configuration # Database Configuration
# ------------------------- # -------------------------
[database] [database]
# SQLAlchemy async connection string. # SQLAlchemy connection string. This one uses SQLite with asyncio support.
# Examples:
# sqlite+aiosqlite:///packets.db
# postgresql+asyncpg://user:pass@host:5432/meshview
connection_string = sqlite+aiosqlite:///packets.db connection_string = sqlite+aiosqlite:///packets.db
> **NOTE (PostgreSQL setup)**
> If you want to use PostgreSQL instead of SQLite:
>
> 1) Install PostgreSQL for your OS.
> 2) Create a user and database:
> - `CREATE USER meshview WITH PASSWORD 'change_me';`
> - `CREATE DATABASE meshview OWNER meshview;`
> 3) Update `config.ini`:
> - `connection_string = postgresql+asyncpg://meshview:change_me@localhost:5432/meshview`
> 4) Initialize the schema:
> - `./env/bin/python startdb.py`
# ------------------------- # -------------------------
# Database Cleanup Configuration # Database Cleanup Configuration
@@ -508,22 +493,10 @@ sleep 5
echo "Run cleanup..." echo "Run cleanup..."
# Run cleanup queries # Run cleanup queries
sqlite3 "$DB_FILE" <<EOF sqlite3 "$DB_FILE" <<EOF
DELETE FROM packet DELETE FROM packet WHERE import_time < datetime('now', '-14 day');
WHERE import_time_us IS NOT NULL DELETE FROM packet_seen WHERE import_time < datetime('now', '-14 day');
AND import_time_us < (strftime('%s','now','-14 days') * 1000000); DELETE FROM traceroute WHERE import_time < datetime('now', '-14 day');
SELECT 'packet deleted: ' || changes(); DELETE FROM node WHERE last_update < datetime('now', '-14 day') OR last_update IS NULL OR last_update = '';
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; VACUUM;
EOF EOF
@@ -1,65 +0,0 @@
"""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,
)
@@ -1,94 +0,0 @@
"""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")
@@ -1,34 +0,0 @@
"""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)
+195 -242
View File
@@ -1,38 +1,25 @@
# API Documentation # API Documentation
Base URL: `http(s)://<host>` ## 1. Chat API
All endpoints return JSON. Timestamps are either ISO 8601 strings or `*_us` values in ### GET `/api/chat`
microseconds since epoch. Returns the most recent chat messages.
## 1. Nodes API **Query Parameters**
- `limit` (optional, int): Maximum number of messages to return. Default: `100`.
### GET `/api/nodes` **Response Example**
Returns a list of nodes, with optional filtering.
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
```json ```json
{ {
"nodes": [ "packets": [
{ {
"id": 42, "id": 123,
"node_id": 1234, "import_time": "2025-07-22T12:45:00",
"long_name": "Alice", "from_node_id": 987654,
"short_name": "A", "from_node": "Alice",
"hw_model": "T-Beam",
"firmware": "1.2.3",
"role": "client",
"last_lat": 377749000,
"last_long": -1224194000,
"channel": "main", "channel": "main",
"last_seen_us": 1736370123456789 "payload": "Hello, world!"
} }
] ]
} }
@@ -40,58 +27,102 @@ Response Example
--- ---
## 2. Packets API ### 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
### GET `/api/nodes`
Returns a list of all nodes, with optional filtering by last seen.
**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.
**Response Example**
```json
{
"nodes": [
{
"node_id": 1234,
"long_name": "Alice",
"short_name": "A",
"channel": "main",
"last_seen": "2025-07-22T12:40:00",
"hardware": "T-Beam",
"firmware": "1.2.3",
"role": "client",
"last_lat": 37.7749,
"last_long": -122.4194
}
]
}
```
---
## 3. Packets API
### GET `/api/packets` ### GET `/api/packets`
Returns packets with optional filters. Returns a list of packets with optional filters.
Query Parameters **Query Parameters**
- `packet_id` (optional, int): Return exactly one packet (overrides other filters). - `limit` (optional, int): Maximum number of packets to return. Default: `200`.
- `limit` (optional, int): Max packets to return, clamped 1-1000. Default: `50`. - `since` (optional, ISO timestamp): Only packets imported after this timestamp are returned.
- `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 ```json
{ {
"packets": [ "packets": [
{ {
"id": 123, "id": 123,
"import_time_us": 1736370123456789,
"channel": "main",
"from_node_id": 5678, "from_node_id": 5678,
"to_node_id": 91011, "to_node_id": 91011,
"portnum": 1, "portnum": 1,
"long_name": "Alice", "import_time": "2025-07-22T12:45:00",
"payload": "Hello, Bob!", "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).
--- ---
## 3. Channels API ## 4. Channels API
### GET `/api/channels` ### GET `/api/channels`
Returns channels seen in a time period. Returns a list of channels seen in a given time period.
Query Parameters **Query Parameters**
- `period_type` (optional, string): `hour` or `day`. Default: `hour`. - `period_type` (optional, string): Time granularity (`hour` or `day`). Default: `hour`.
- `length` (optional, int): Number of periods to look back. Default: `24`. - `length` (optional, int): Number of periods to look back. Default: `24`.
Response Example **Response Example**
```json ```json
{ {
"channels": ["LongFast", "MediumFast", "ShortFast"] "channels": ["LongFast", "MediumFast", "ShortFast"]
@@ -100,21 +131,29 @@ Response Example
--- ---
## 4. Stats API ## 5. Statistics API
### GET `/api/stats` ### GET `/api/stats`
Returns packet statistics aggregated by time periods, with optional filtering.
Query Parameters Retrieve packet statistics aggregated by time periods, with optional filtering.
- `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. ## Query Parameters
- `to_node` (optional, int): Filter by destination node ID.
- `from_node` (optional, int): Filter by source node ID. | Parameter | Type | Required | Default | Description |
- `node` (optional, int): If provided, return combined `sent` and `seen` totals for that node. |--------------|---------|----------|----------|-------------------------------------------------------------------------------------------------|
| `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
Response Example (series)
```json ```json
{ {
"period_type": "hour", "period_type": "hour",
@@ -124,71 +163,42 @@ Response Example (series)
"to_node": 12345678, "to_node": 12345678,
"from_node": 87654321, "from_node": 87654321,
"data": [ "data": [
{ "period": "2025-08-08 14:00", "count": 10 }, {
{ "period": "2025-08-08 15:00", "count": 7 } "period": "2025-08-08 14:00",
"count": 10
},
{
"period": "2025-08-08 15:00",
"count": 7
}
// more entries...
] ]
} }
``` ```
Response Example (`node` totals)
```json
{
"node_id": 12345678,
"period_type": "hour",
"length": 24,
"sent": 42,
"seen": 58
}
```
--- ---
### GET `/api/stats/count` ## 6. Edges API
Returns total packet counts, optionally filtered.
Query Parameters ### GET `/api/edges`
- `packet_id` (optional, int): Filter packet_seen by packet ID. Returns network edges (connections between nodes) based on traceroutes and neighbor info.
- `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 **Query Parameters**
- `type` (optional, string): Filter by edge type (`traceroute` or `neighbor`). If omitted, returns both types.
**Response Example**
```json ```json
{ {
"total_packets": 12345, "edges": [
"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": [
{ {
"node_id": 1234, "from": 12345678,
"long_name": "Alice", "to": 87654321,
"short_name": "A", "type": "traceroute"
"channel": "main", },
"sent": 100, {
"seen": 240, "from": 11111111,
"avg": 2.4 "to": 22222222,
"type": "neighbor"
} }
] ]
} }
@@ -196,45 +206,22 @@ Response Example
--- ---
## 5. Edges API ## 7. Configuration API
### GET `/api/edges`
Returns network edges (connections between nodes) based on traceroutes and neighbor info.
Traceroute edges are collected over the last 48 hours. Neighbor edges are based on
port 71 packets.
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
```json
{
"edges": [
{ "from": 12345678, "to": 87654321, "type": "traceroute" },
{ "from": 11111111, "to": 22222222, "type": "neighbor" }
]
}
```
---
## 6. Config API
### GET `/api/config` ### GET `/api/config`
Returns a safe subset of server configuration. Returns the current site configuration (safe subset exposed to clients).
Response Example **Response Example**
```json ```json
{ {
"site": { "site": {
"domain": "example.com", "domain": "meshview.example.com",
"language": "en", "language": "en",
"title": "Meshview", "title": "Bay Area Mesh",
"message": "", "message": "Real time data from around the bay area",
"starting": "/chat", "starting": "/chat",
"nodes": "true", "nodes": "true",
"chat": "true", "conversations": "true",
"everything": "true", "everything": "true",
"graphs": "true", "graphs": "true",
"stats": "true", "stats": "true",
@@ -249,11 +236,11 @@ Response Example
"firehose_interval": 3, "firehose_interval": 3,
"weekly_net_message": "Weekly Mesh check-in message.", "weekly_net_message": "Weekly Mesh check-in message.",
"net_tag": "#BayMeshNet", "net_tag": "#BayMeshNet",
"version": "3.0.0" "version": "2.0.8 ~ 10-22-25"
}, },
"mqtt": { "mqtt": {
"server": "mqtt.example.com", "server": "mqtt.bayme.sh",
"topics": ["msh/region/#"] "topics": ["msh/US/bayarea/#"]
}, },
"cleanup": { "cleanup": {
"enabled": "false", "enabled": "false",
@@ -267,125 +254,91 @@ Response Example
--- ---
## 7. Language API ## 8. Language/Translations API
### GET `/api/lang` ### GET `/api/lang`
Returns translation strings. Returns translation strings for the UI.
Query Parameters **Query Parameters**
- `lang` (optional, string): Language code (e.g., `en`, `es`). Default from config or `en`. - `lang` (optional, string): Language code (e.g., `en`, `es`). Defaults to site language setting.
- `section` (optional, string): Return only one section (e.g., `nodelist`, `firehose`). - `section` (optional, string): Specific section to retrieve translations for.
Response Example **Response Example (full)**
```json ```json
{ {
"title": "Meshview", "chat": {
"search_placeholder": "Search..." "title": "Chat",
} "send": "Send"
```
---
## 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": [ "map": {
{ "title": "Map",
"index": 0, "zoom_in": "Zoom In"
"gateway_node_id": 333, }
"done": true, }
"forward_hops": [111, 444, 222], ```
"reverse_hops": [222, 444, 111]
} **Response Example (section-specific)**
], Request: `/api/lang?section=chat`
"unique_forward_paths": [ ```json
{ "path": [111, 444, 222], "count": 2 } {
], "title": "Chat",
"unique_reverse_paths": [ "send": "Send"
[222, 444, 111]
],
"winning_paths": [
[111, 444, 222]
]
} }
``` ```
--- ---
## 10. Health API ## 9. Health Check API
### GET `/health` ### GET `/health`
Returns service health and database status. Health check endpoint for monitoring, load balancers, and orchestration systems.
Response Example **Response Example (Healthy)**
```json ```json
{ {
"status": "healthy", "status": "healthy",
"timestamp": "2025-07-22T12:45:00+00:00", "timestamp": "2025-11-03T14:30:00.123456Z",
"version": "3.0.0", "version": "3.0.0",
"git_revision": "abc1234", "git_revision": "6416978",
"database": "connected", "database": "connected",
"database_size": "12.34 MB", "database_size": "853.03 MB",
"database_size_bytes": 12939444 "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"
} }
``` ```
--- ---
## 11. Version API ## 10. Version API
### GET `/version` ### GET `/version`
Returns version metadata. Returns detailed version information including semver, release date, and git revision.
Response Example **Response Example**
```json ```json
{ {
"version": "3.0.0", "version": "2.0.8",
"git_revision": "abc1234", "release_date": "2025-10-22",
"build_time": "2025-11-01T12:00:00+00:00" "git_revision": "6416978a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q",
"git_revision_short": "6416978"
} }
``` ```
---
## 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`).
+2 -2
View File
@@ -3,8 +3,8 @@
import subprocess import subprocess
from pathlib import Path from pathlib import Path
__version__ = "3.0.2" __version__ = "3.0.1"
__release_date__ = "2026-1-9" __release_date__ = "2025-12-4"
def get_git_revision(): def get_git_revision():
+1 -1
View File
@@ -6,7 +6,7 @@ parser = argparse.ArgumentParser(description="MeshView Configuration Loader")
parser.add_argument( parser.add_argument(
"--config", type=str, default="config.ini", help="Path to config.ini file (default: config.ini)" "--config", type=str, default="config.ini", help="Path to config.ini file (default: config.ini)"
) )
args, _ = parser.parse_known_args() args = parser.parse_args()
# Initialize config parser # Initialize config parser
config_parser = configparser.ConfigParser() config_parser = configparser.ConfigParser()
+4 -14
View File
@@ -1,4 +1,3 @@
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from meshview import models from meshview import models
@@ -10,19 +9,10 @@ async_session = None
def init_database(database_connection_string): def init_database(database_connection_string):
global engine, async_session global engine, async_session
kwargs = {"echo": False} kwargs = {"echo": False}
url = make_url(database_connection_string) # Ensure SQLite is opened in read-only mode
connect_args = {} database_connection_string += "?mode=ro"
kwargs["connect_args"] = {"uri": True}
if url.drivername.startswith("sqlite"): engine = create_async_engine(database_connection_string, **kwargs)
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( async_session = async_sessionmaker(
bind=engine, bind=engine,
class_=AsyncSession, class_=AsyncSession,
+5 -10
View File
@@ -186,24 +186,19 @@ async def create_migration_status_table(engine: AsyncEngine) -> None:
text(""" text("""
CREATE TABLE IF NOT EXISTS migration_status ( CREATE TABLE IF NOT EXISTS migration_status (
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
in_progress BOOLEAN NOT NULL DEFAULT FALSE, in_progress BOOLEAN NOT NULL DEFAULT 0,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) )
""") """)
) )
result = await conn.execute( # Insert initial row if not exists
await conn.execute(
text(""" text("""
SELECT 1 FROM migration_status WHERE id = 1 INSERT OR IGNORE INTO migration_status (id, in_progress)
VALUES (1, 0)
""") """)
) )
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: async def set_migration_in_progress(engine: AsyncEngine, in_progress: bool) -> None:
+18 -2
View File
@@ -1,3 +1,5 @@
from datetime import datetime
from sqlalchemy import BigInteger, ForeignKey, Index, desc from sqlalchemy import BigInteger, ForeignKey, Index, desc
from sqlalchemy.ext.asyncio import AsyncAttrs from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
@@ -20,6 +22,7 @@ class Node(Base):
last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True) last_lat: Mapped[int] = mapped_column(BigInteger, nullable=True)
last_long: Mapped[int] = mapped_column(BigInteger, nullable=True) last_long: Mapped[int] = mapped_column(BigInteger, nullable=True)
channel: Mapped[str] = mapped_column(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) first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True) last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
@@ -30,7 +33,11 @@ class Node(Base):
) )
def to_dict(self): def to_dict(self):
return {column.name: getattr(self, column.name) for column in self.__table__.columns} return {
column.name: getattr(self, column.name)
for column in self.__table__.columns
if column.name != "last_update"
}
class Packet(Base): class Packet(Base):
@@ -48,13 +55,17 @@ class Packet(Base):
overlaps="from_node", overlaps="from_node",
) )
payload: Mapped[bytes] = mapped_column(nullable=True) 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) import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
channel: Mapped[str] = mapped_column(nullable=True) channel: Mapped[str] = mapped_column(nullable=True)
__table_args__ = ( __table_args__ = (
Index("idx_packet_from_node_id", "from_node_id"), Index("idx_packet_from_node_id", "from_node_id"),
Index("idx_packet_to_node_id", "to_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")), 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")), Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
) )
@@ -75,6 +86,7 @@ class PacketSeen(Base):
rx_snr: Mapped[float] = mapped_column(nullable=True) rx_snr: Mapped[float] = mapped_column(nullable=True)
rx_rssi: Mapped[int] = mapped_column(nullable=True) rx_rssi: Mapped[int] = mapped_column(nullable=True)
topic: Mapped[str] = 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) import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = ( __table_args__ = (
@@ -96,7 +108,11 @@ class Traceroute(Base):
gateway_node_id: Mapped[int] = mapped_column(BigInteger, nullable=True) gateway_node_id: Mapped[int] = mapped_column(BigInteger, nullable=True)
done: Mapped[bool] = mapped_column(nullable=True) done: Mapped[bool] = mapped_column(nullable=True)
route: Mapped[bytes] = 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) route_return: Mapped[bytes] = mapped_column(nullable=True)
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True) import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
__table_args__ = (Index("idx_traceroute_import_time_us", "import_time_us"),) __table_args__ = (
Index("idx_traceroute_import_time", "import_time"),
Index("idx_traceroute_import_time_us", "import_time_us"),
)
+3 -6
View File
@@ -1,4 +1,3 @@
from sqlalchemy.engine.url import make_url
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from meshview import models from meshview import models
@@ -6,11 +5,9 @@ from meshview import models
def init_database(database_connection_string): def init_database(database_connection_string):
global engine, async_session global engine, async_session
url = make_url(database_connection_string) engine = create_async_engine(
kwargs = {"echo": False} database_connection_string, echo=False, connect_args={"timeout": 900}
if url.drivername.startswith("sqlite"): )
kwargs["connect_args"] = {"timeout": 900}
engine = create_async_engine(url, **kwargs)
async_session = async_sessionmaker(engine, expire_on_commit=False) async_session = async_sessionmaker(engine, expire_on_commit=False)
+41 -45
View File
@@ -1,12 +1,8 @@
import datetime import datetime
import logging
import re import re
import time
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_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.config_pb2 import Config
from meshtastic.protobuf.mesh_pb2 import HardwareModel from meshtastic.protobuf.mesh_pb2 import HardwareModel
@@ -14,8 +10,6 @@ from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import decode_payload, mqtt_database from meshview import decode_payload, mqtt_database
from meshview.models import Node, Packet, PacketSeen, Traceroute from meshview.models import Node, Packet, PacketSeen, Traceroute
logger = logging.getLogger(__name__)
async def process_envelope(topic, env): async def process_envelope(topic, env):
# MAP_REPORT_APP # MAP_REPORT_APP
@@ -43,7 +37,8 @@ async def process_envelope(topic, env):
await session.execute(select(Node).where(Node.node_id == node_id)) await session.execute(select(Node).where(Node.node_id == node_id))
).scalar_one_or_none() ).scalar_one_or_none()
now_us = int(time.time() * 1_000_000) now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
if node: if node:
node.node_id = node_id node.node_id = node_id
@@ -55,6 +50,7 @@ async def process_envelope(topic, env):
node.last_lat = map_report.latitude_i node.last_lat = map_report.latitude_i
node.last_long = map_report.longitude_i node.last_long = map_report.longitude_i
node.firmware = map_report.firmware_version node.firmware = map_report.firmware_version
node.last_update = now
node.last_seen_us = now_us node.last_seen_us = now_us
if node.first_seen_us is None: if node.first_seen_us is None:
node.first_seen_us = now_us node.first_seen_us = now_us
@@ -70,6 +66,7 @@ async def process_envelope(topic, env):
firmware=map_report.firmware_version, firmware=map_report.firmware_version,
last_lat=map_report.latitude_i, last_lat=map_report.latitude_i,
last_long=map_report.longitude_i, last_long=map_report.longitude_i,
last_update=now,
first_seen_us=now_us, first_seen_us=now_us,
last_seen_us=now_us, last_seen_us=now_us,
) )
@@ -85,43 +82,29 @@ async def process_envelope(topic, env):
async with mqtt_database.async_session() as session: async with mqtt_database.async_session() as session:
# --- Packet insert with ON CONFLICT DO NOTHING # --- Packet insert with ON CONFLICT DO NOTHING
result = await session.execute(select(Packet).where(Packet.id == env.packet.id)) result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
# FIXME: Not Used
# new_packet = False
packet = result.scalar_one_or_none() packet = result.scalar_one_or_none()
if not packet: if not packet:
now_us = int(time.time() * 1_000_000) # FIXME: Not Used
packet_values = { # new_packet = True
"id": env.packet.id, now = datetime.datetime.now(datetime.UTC)
"portnum": env.packet.decoded.portnum, now_us = int(now.timestamp() * 1_000_000)
"from_node_id": getattr(env.packet, "from"), stmt = (
"to_node_id": env.packet.to, sqlite_insert(Packet)
"payload": env.packet.SerializeToString(), .values(
"import_time_us": now_us, id=env.packet.id,
"channel": env.channel_id, portnum=env.packet.decoded.portnum,
} from_node_id=getattr(env.packet, "from"),
utc_time = datetime.datetime.fromtimestamp(now_us / 1_000_000, datetime.UTC) to_node_id=env.packet.to,
dialect = session.get_bind().dialect.name payload=env.packet.SerializeToString(),
stmt = None import_time=now,
if dialect == "sqlite": import_time_us=now_us,
stmt = ( channel=env.channel_id,
sqlite_insert(Packet)
.values(**packet_values)
.on_conflict_do_nothing(index_elements=["id"])
) )
elif dialect == "postgresql": .on_conflict_do_nothing(index_elements=["id"])
stmt = ( )
pg_insert(Packet) await session.execute(stmt)
.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) # --- PacketSeen (no conflict handling here, normal insert)
@@ -140,7 +123,8 @@ async def process_envelope(topic, env):
) )
) )
if not result.scalar_one_or_none(): if not result.scalar_one_or_none():
now_us = int(time.time() * 1_000_000) now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
seen = PacketSeen( seen = PacketSeen(
packet_id=env.packet.id, packet_id=env.packet.id,
node_id=int(env.gateway_id[1:], 16), node_id=int(env.gateway_id[1:], 16),
@@ -151,6 +135,7 @@ async def process_envelope(topic, env):
hop_limit=env.packet.hop_limit, hop_limit=env.packet.hop_limit,
hop_start=env.packet.hop_start, hop_start=env.packet.hop_start,
topic=topic, topic=topic,
import_time=now,
import_time_us=now_us, import_time_us=now_us,
) )
session.add(seen) session.add(seen)
@@ -182,7 +167,8 @@ async def process_envelope(topic, env):
await session.execute(select(Node).where(Node.id == user.id)) await session.execute(select(Node).where(Node.id == user.id))
).scalar_one_or_none() ).scalar_one_or_none()
now_us = int(time.time() * 1_000_000) now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
if node: if node:
node.node_id = node_id node.node_id = node_id
@@ -191,6 +177,7 @@ async def process_envelope(topic, env):
node.hw_model = hw_model node.hw_model = hw_model
node.role = role node.role = role
node.channel = env.channel_id node.channel = env.channel_id
node.last_update = now
node.last_seen_us = now_us node.last_seen_us = now_us
if node.first_seen_us is None: if node.first_seen_us is None:
node.first_seen_us = now_us node.first_seen_us = now_us
@@ -203,6 +190,7 @@ async def process_envelope(topic, env):
hw_model=hw_model, hw_model=hw_model,
role=role, role=role,
channel=env.channel_id, channel=env.channel_id,
last_update=now,
first_seen_us=now_us, first_seen_us=now_us,
last_seen_us=now_us, last_seen_us=now_us,
) )
@@ -221,9 +209,11 @@ async def process_envelope(topic, env):
await session.execute(select(Node).where(Node.node_id == from_node_id)) await session.execute(select(Node).where(Node.node_id == from_node_id))
).scalar_one_or_none() ).scalar_one_or_none()
if node: if node:
now_us = int(time.time() * 1_000_000) now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
node.last_lat = position.latitude_i node.last_lat = position.latitude_i
node.last_long = position.longitude_i node.last_long = position.longitude_i
node.last_update = now
node.last_seen_us = now_us node.last_seen_us = now_us
if node.first_seen_us is None: if node.first_seen_us is None:
node.first_seen_us = now_us node.first_seen_us = now_us
@@ -233,15 +223,21 @@ async def process_envelope(topic, env):
if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP: if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP:
packet_id = env.packet.id packet_id = env.packet.id
if packet_id is not None: if packet_id is not None:
now_us = int(time.time() * 1_000_000) now = datetime.datetime.now(datetime.UTC)
now_us = int(now.timestamp() * 1_000_000)
session.add( session.add(
Traceroute( Traceroute(
packet_id=packet_id, packet_id=packet_id,
route=env.packet.decoded.payload, route=env.packet.decoded.payload,
done=not env.packet.decoded.want_response, done=not env.packet.decoded.want_response,
gateway_node_id=int(env.gateway_id[1:], 16), gateway_node_id=int(env.gateway_id[1:], 16),
import_time=now,
import_time_us=now_us, import_time_us=now_us,
) )
) )
await session.commit() await session.commit()
# if new_packet:
# await packet.awaitable_attrs.to_node
# await packet.awaitable_attrs.from_node
+3 -3
View File
@@ -75,8 +75,8 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
return color; return color;
} }
function timeAgoFromUs(us){ function timeAgo(dateStr){
const diff = Date.now() - (us / 1000); const diff = Date.now() - new Date(dateStr);
const s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24); 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'; 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>Channel:</b> ${node.channel}<br>
<b>Model:</b> ${node.hw_model}<br> <b>Model:</b> ${node.hw_model}<br>
<b>Role:</b> ${node.role}<br>`; <b>Role:</b> ${node.role}<br>`;
if(node.last_seen_us) popupContent+=`<b>Last seen:</b> ${timeAgoFromUs(node.last_seen_us)}<br>`; if(node.last_update) popupContent+=`<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`;
if(node.firmware) popupContent+=`<b>Firmware:</b> ${node.firmware}<br>`; if(node.firmware) popupContent+=`<b>Firmware:</b> ${node.firmware}<br>`;
marker.on('click', e=>{ marker.on('click', e=>{
-36
View File
@@ -1,36 +0,0 @@
// 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;
+62 -96
View File
@@ -1,14 +1,10 @@
import logging from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone from sqlalchemy import select, and_, or_, func, cast, Text
from sqlalchemy import Text, and_, cast, func, or_, select
from sqlalchemy.orm import lazyload from sqlalchemy.orm import lazyload
from meshview import database, models from meshview import database, models
from meshview.models import Node, Packet, PacketSeen, Traceroute from meshview.models import Node, Packet, PacketSeen, Traceroute
logger = logging.getLogger(__name__)
async def get_node(node_id): async def get_node(node_id):
async with database.async_session() as session: async with database.async_session() as session:
@@ -95,10 +91,8 @@ async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
if portnum: if portnum:
q = q.where(Packet.portnum == portnum) q = q.where(Packet.portnum == portnum)
if since: if since:
now_us = int(datetime.now().timestamp() * 1_000_000) q = q.where(Packet.import_time > (datetime.now() - since))
start_us = now_us - int(since.total_seconds() * 1_000_000) result = await session.execute(q.limit(limit).order_by(Packet.import_time.desc()))
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() return result.scalars()
@@ -114,7 +108,7 @@ async def get_packets_seen(packet_id):
result = await session.execute( result = await session.execute(
select(PacketSeen) select(PacketSeen)
.where(PacketSeen.packet_id == packet_id) .where(PacketSeen.packet_id == packet_id)
.order_by(PacketSeen.import_time_us.desc()) .order_by(PacketSeen.import_time.desc())
) )
return result.scalars() return result.scalars()
@@ -135,21 +129,18 @@ async def get_traceroute(packet_id):
result = await session.execute( result = await session.execute(
select(Traceroute) select(Traceroute)
.where(Traceroute.packet_id == packet_id) .where(Traceroute.packet_id == packet_id)
.order_by(Traceroute.import_time_us) .order_by(Traceroute.import_time)
) )
return result.scalars() return result.scalars()
async def get_traceroutes(since): 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: async with database.async_session() as session:
stmt = ( stmt = (
select(Traceroute) select(Traceroute)
.where(Traceroute.import_time_us > since_us) .join(Packet)
.order_by(Traceroute.import_time_us) .where(Traceroute.import_time > since)
.order_by(Traceroute.import_time)
) )
stream = await session.stream_scalars(stmt) stream = await session.stream_scalars(stmt)
async for tr in stream: async for tr in stream:
@@ -157,8 +148,6 @@ async def get_traceroutes(since):
async def get_mqtt_neighbors(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: async with database.async_session() as session:
result = await session.execute( result = await session.execute(
select(PacketSeen, Packet) select(PacketSeen, Packet)
@@ -166,7 +155,7 @@ async def get_mqtt_neighbors(since):
.where( .where(
(PacketSeen.hop_limit == PacketSeen.hop_start) (PacketSeen.hop_limit == PacketSeen.hop_start)
& (PacketSeen.hop_start != 0) & (PacketSeen.hop_start != 0)
& (PacketSeen.import_time_us > start_us) & (PacketSeen.import_time > (datetime.now() - since))
) )
.options( .options(
lazyload(Packet.from_node), lazyload(Packet.from_node),
@@ -179,9 +168,9 @@ async def get_mqtt_neighbors(since):
async def get_total_node_count(channel: str = None) -> int: async def get_total_node_count(channel: str = None) -> int:
try: try:
async with database.async_session() as session: async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) q = select(func.count(Node.id)).where(
cutoff_us = now_us - 86400 * 1_000_000 Node.last_update > datetime.now() - timedelta(days=1)
q = select(func.count(Node.id)).where(Node.last_seen_us > cutoff_us) )
if channel: if channel:
q = q.where(Node.channel == channel) q = q.where(Node.channel == channel)
@@ -196,32 +185,26 @@ async def get_total_node_count(channel: str = None) -> int:
async def get_top_traffic_nodes(): async def get_top_traffic_nodes():
try: try:
async with database.async_session() as session: async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) result = await session.execute(
cutoff_us = now_us - 86400 * 1_000_000 text("""
total_packets_sent = func.count(func.distinct(Packet.id)).label("total_packets_sent") SELECT
total_times_seen = func.count(PacketSeen.packet_id).label("total_times_seen") n.node_id,
n.long_name,
stmt = ( n.short_name,
select( n.channel,
Node.node_id, COUNT(DISTINCT p.id) AS total_packets_sent,
Node.long_name, COUNT(ps.packet_id) AS total_times_seen
Node.short_name, FROM node n
Node.channel, LEFT JOIN packet p ON n.node_id = p.from_node_id
total_packets_sent, AND p.import_time >= DATETIME('now', 'localtime', '-24 hours')
total_times_seen, LEFT JOIN packet_seen ps ON p.id = ps.packet_id
) GROUP BY n.node_id, n.long_name, n.short_name
.select_from(Node) HAVING total_packets_sent > 0
.outerjoin( ORDER BY total_times_seen DESC;
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 = (await session.execute(stmt)).all() rows = result.fetchall()
nodes = [ nodes = [
{ {
@@ -244,30 +227,33 @@ async def get_top_traffic_nodes():
async def get_node_traffic(node_id: int): async def get_node_traffic(node_id: int):
try: try:
async with database.async_session() as session: async with database.async_session() as session:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) result = await session.execute(
cutoff_us = now_us - 86400 * 1_000_000 text("""
packet_count = func.count().label("packet_count") SELECT
node.long_name, packet.portnum,
stmt = ( COUNT(*) AS packet_count
select(Node.long_name, Packet.portnum, packet_count) FROM packet
.select_from(Packet) JOIN node ON packet.from_node_id = node.node_id
.join(Node, Packet.from_node_id == Node.node_id) WHERE node.node_id = :node_id
.where(Node.node_id == node_id) AND packet.import_time >= DATETIME('now', 'localtime', '-24 hours')
.where(Packet.import_time_us >= cutoff_us) GROUP BY packet.portnum
.group_by(Node.long_name, Packet.portnum) ORDER BY packet_count DESC;
.order_by(packet_count.desc()) """),
{"node_id": node_id},
) )
result = await session.execute(stmt) # Map the result to include node.long_name and packet data
return [ traffic_data = [
{ {
"long_name": row.long_name, "long_name": row[0], # node.long_name
"portnum": row.portnum, "portnum": row[1], # packet.portnum
"packet_count": row.packet_count, "packet_count": row[2], # COUNT(*) as packet_count
} }
for row in result.all() for row in result.all()
] ]
return traffic_data
except Exception as e: except Exception as e:
# Log the error or handle it as needed # Log the error or handle it as needed
print(f"Error fetching node traffic: {str(e)}") print(f"Error fetching node traffic: {str(e)}")
@@ -296,11 +282,7 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
# Apply filters based on provided parameters # Apply filters based on provided parameters
if node_id is not None: if node_id is not None:
try: query = query.where(Node.node_id == node_id)
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: if role is not None:
query = query.where(Node.role == role.upper()) # Ensure role is uppercase query = query.where(Node.role == role.upper()) # Ensure role is uppercase
if channel is not None: if channel is not None:
@@ -309,12 +291,10 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
query = query.where(Node.hw_model == hw_model) query = query.where(Node.hw_model == hw_model)
if days_active is not None: if days_active is not None:
now_us = int(datetime.now(timezone.utc).timestamp() * 1_000_000) query = query.where(Node.last_update > datetime.now() - timedelta(days_active))
cutoff_us = now_us - int(timedelta(days_active).total_seconds() * 1_000_000)
query = query.where(Node.last_seen_us > cutoff_us)
# Exclude nodes with missing last_seen_us # Exclude nodes where last_update is an empty string
query = query.where(Node.last_seen_us.is_not(None)) query = query.where(Node.last_update != "")
# Order results by long_name in ascending order # Order results by long_name in ascending order
query = query.order_by(Node.short_name.asc()) query = query.order_by(Node.short_name.asc())
@@ -325,7 +305,7 @@ async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_a
return nodes # Return the list of nodes return nodes # Return the list of nodes
except Exception: except Exception:
logger.exception("error reading DB") print("error reading DB") # Consider using logging instead of print
return [] # Return an empty list in case of failure return [] # Return an empty list in case of failure
@@ -337,36 +317,22 @@ async def get_packet_stats(
to_node: int | None = None, to_node: int | None = None,
from_node: int | None = None, from_node: int | None = None,
): ):
now = datetime.now(timezone.utc) now = datetime.now()
if period_type == "hour": if period_type == "hour":
start_time = now - timedelta(hours=length) start_time = now - timedelta(hours=length)
time_format_sqlite = "%Y-%m-%d %H:00" time_format = '%Y-%m-%d %H:00'
time_format_pg = "YYYY-MM-DD HH24:00"
elif period_type == "day": elif period_type == "day":
start_time = now - timedelta(days=length) start_time = now - timedelta(days=length)
time_format_sqlite = "%Y-%m-%d" time_format = '%Y-%m-%d'
time_format_pg = "YYYY-MM-DD"
else: else:
raise ValueError("period_type must be 'hour' or 'day'") raise ValueError("period_type must be 'hour' or 'day'")
async with database.async_session() as session: 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( q = select(
period_expr.label("period"), func.strftime(time_format, Packet.import_time).label('period'),
func.count().label("count"), func.count().label('count'),
).where(Packet.import_time_us >= int(start_time.timestamp() * 1_000_000)) ).where(Packet.import_time >= start_time)
# Filters # Filters
if channel: if channel:
+41 -36
View File
@@ -115,7 +115,6 @@
</div> </div>
<script src="/static/portmaps.js"></script>
<script> <script>
/* ====================================================== /* ======================================================
FIREHOSE TRANSLATION SYSTEM (isolated from base) FIREHOSE TRANSLATION SYSTEM (isolated from base)
@@ -178,8 +177,41 @@ function nodeName(id) {
/* ====================================================== /* ======================================================
PORT COLORS & NAMES PORT COLORS & NAMES
====================================================== */ ====================================================== */
const PORT_MAP = window.PORT_MAP || {}; const PORT_MAP = {
const PORT_COLORS = window.PORT_COLORS || {}; 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"
};
function portLabel(portnum, payload, linksHtml) { function portLabel(portnum, payload, linksHtml) {
const name = PORT_MAP[portnum] || "Unknown"; const name = PORT_MAP[portnum] || "Unknown";
@@ -201,37 +233,13 @@ function portLabel(portnum, payload, linksHtml) {
/* ====================================================== /* ======================================================
TIME FORMAT TIME FORMAT
====================================================== */ ====================================================== */
function formatTimes(importTimeUs) { function formatLocalTime(importTimeUs) {
const ms = Number(importTimeUs) / 1000; const ms = importTimeUs / 1000;
if (!Number.isFinite(ms)) { return new Date(ms).toLocaleTimeString([], {
return { local: "—", utc: "—", epoch: "—" };
}
const date = new Date(ms);
const local = date.toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "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
);
} }
/* ====================================================== /* ======================================================
@@ -253,7 +261,7 @@ async function fetchUpdates() {
if (updatesPaused) return; if (updatesPaused) return;
const url = new URL("/api/packets", window.location.origin); const url = new URL("/api/packets", window.location.origin);
url.searchParams.set("limit", 100); url.searchParams.set("limit", 50);
if (lastImportTimeUs) if (lastImportTimeUs)
url.searchParams.set("since", lastImportTimeUs); url.searchParams.set("since", lastImportTimeUs);
@@ -269,7 +277,6 @@ async function fetchUpdates() {
const list = document.getElementById("packet_list"); const list = document.getElementById("packet_list");
for (const pkt of packets.reverse()) { for (const pkt of packets.reverse()) {
logPacketTimes(pkt);
/* FROM — includes translation */ /* FROM — includes translation */
const from = const from =
@@ -329,9 +336,7 @@ async function fetchUpdates() {
const html = ` const html = `
<tr class="packet-row"> <tr class="packet-row">
<td> <td>${formatLocalTime(pkt.import_time_us)}</td>
${formatTimes(pkt.import_time_us).local}<br>
</td>
<td> <td>
<span class="toggle-btn">▶</span> <span class="toggle-btn">▶</span>
+5 -5
View File
@@ -140,8 +140,8 @@ map.on("popupopen", function (e) {
if (popupEl) applyTranslationsMap(popupEl); if (popupEl) applyTranslationsMap(popupEl);
}); });
function timeAgoFromUs(us){ function timeAgo(date){
const diff = Date.now() - (us / 1000); const diff = Date.now() - new Date(date);
const s = Math.floor(diff/1000), m = Math.floor(s/60), const s = Math.floor(diff/1000), m = Math.floor(s/60),
h = Math.floor(m/60), d = Math.floor(h/24); 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"; 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 || "", hw_model: n.hw_model || "",
role: n.role || "", role: n.role || "",
firmware: n.firmware || "", firmware: n.firmware || "",
last_seen_us: n.last_seen_us || null, last_update: n.last_update || "",
isRouter: (n.role||"").toLowerCase().includes("router") isRouter: (n.role||"").toLowerCase().includes("router")
})); }));
@@ -333,8 +333,8 @@ function renderNodesOnMap(){
<b data-translate-lang="role_label"></b> ${node.role}<br> <b data-translate-lang="role_label"></b> ${node.role}<br>
${ ${
node.last_seen_us node.last_update
? `<b data-translate-lang="last_seen"></b> ${timeAgoFromUs(node.last_seen_us)}<br>` ? `<b data-translate-lang="last_seen"></b> ${timeAgo(node.last_update)}<br>`
: "" : ""
} }
+1 -1
View File
@@ -141,7 +141,7 @@ document.addEventListener("DOMContentLoaded", async () => {
</span> </span>
<span class="col-3 nodename"> <span class="col-3 nodename">
<a href="/node/${packet.from_node_id}"> <a href="/packet_list/${packet.from_node_id}">
${escapeHtml(fromName)} ${escapeHtml(fromName)}
</a> </a>
</span> </span>
+91 -169
View File
@@ -258,17 +258,15 @@
<!-- Packets --> <!-- Packets -->
<table class="packet-table"> <table class="packet-table">
<thead> <thead>
<tr> <tr>
<th data-translate-lang="time">Time</th> <th data-translate-lang="time">Time</th>
<th data-translate-lang="packet_id">Packet ID</th> <th data-translate-lang="packet_id">Packet ID</th>
<th data-translate-lang="from">From</th> <th data-translate-lang="from">From</th>
<th data-translate-lang="to">To</th> <th data-translate-lang="to">To</th>
<th data-translate-lang="port">Port</th> <th data-translate-lang="port">Port</th>
<th data-translate-lang="size">Size</th> </tr>
</tr> </thead>
</thead>
<tbody id="packet_list"></tbody> <tbody id="packet_list"></tbody>
</table> </table>
@@ -285,12 +283,34 @@
</div> </div>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/echarts@5.5.0/dist/echarts.min.js"></script>
<script src="/static/portmaps.js"></script>
<script> <script>
const PORT_COLOR_MAP = window.PORT_COLOR_MAP || {}; const PORT_COLOR_MAP = {
const PORT_LABEL_MAP = window.PORT_LABEL_MAP || {}; 0: "#6c757d",
1: "#007bff",
3: "#28a745",
4: "#ffc107",
5: "#dc3545",
6: "#20c997",
65: "#6610f2",
67: "#17a2b8",
70: "#ff9800",
71: "#ff66cc",
};
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"
};
/* ====================================================== /* ======================================================
NODE PAGE TRANSLATION (isolated from base) NODE PAGE TRANSLATION (isolated from base)
@@ -588,16 +608,10 @@ function addMarker(id, lat, lon, color = "red", node = null) {
m.bringToFront(); m.bringToFront();
} }
async function drawNeighbors(src, nids) { async function drawNeighbors(src, nids){
if (!map) return; if (!map) return;
const srcPos = nodePositions[src];
// Ensure source node position exists if (!srcPos) return;
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) { for (const nid of nids) {
const neighbor = await fetchNodeFromApi(nid); const neighbor = await fetchNodeFromApi(nid);
@@ -606,22 +620,13 @@ async function drawNeighbors(src, nids) {
const lat = neighbor.last_lat / 1e7; const lat = neighbor.last_lat / 1e7;
const lon = neighbor.last_long / 1e7; const lon = neighbor.last_long / 1e7;
nodePositions[nid] = [lat, lon];
// Marker
addMarker(nid, lat, lon, "blue", neighbor); addMarker(nid, lat, lon, "blue", neighbor);
// Link line const dstPos = [lat, lon];
L.polyline( L.polyline([srcPos, dstPos], { color:'gray', weight:1 }).addTo(map);
[[srcLat, srcLon], [lat, lon]],
{ color: "gray", weight: 1 }
).addTo(map);
} }
ensureMapVisible();
} }
function ensureMapVisible(){ function ensureMapVisible(){
if (!map) return; if (!map) return;
requestAnimationFrame(() => { requestAnimationFrame(() => {
@@ -753,11 +758,8 @@ async function loadPackets(filters = {}) {
const packets = data.packets || []; const packets = data.packets || [];
currentPacketRows = packets; currentPacketRows = packets;
for (const pkt of packets.reverse()) {
// ================================ for (const pkt of (data.packets || []).reverse()) {
// TABLE ROW
// ================================
const safePayload = (pkt.payload || "") const safePayload = (pkt.payload || "")
.replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;")); .replace(/[<>]/g, m => (m === "<" ? "&lt;" : "&gt;"));
@@ -786,30 +788,25 @@ async function loadPackets(filters = {}) {
` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`; ` <a class="inline-link" href="/graph/traceroute/${traceId}" target="_blank">⮕</a>`;
} }
const sizeBytes = packetSizeBytes(pkt);
list.insertAdjacentHTML("afterbegin", ` list.insertAdjacentHTML("afterbegin", `
<tr class="packet-row"> <tr class="packet-row">
<td>${localTime}</td> <td>${localTime}</td>
<td><span class="toggle-btn">▶</span> <td><span class="toggle-btn">▶</span>
<a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;"> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">
${pkt.id} ${pkt.id}
</a> </a>
</td> </td>
<td>${fromCell}</td> <td>${fromCell}</td>
<td>${toCell}</td> <td>${toCell}</td>
<td>${portLabel(pkt.portnum)}${inlineLinks}</td> <td>${portLabel(pkt.portnum)}${inlineLinks}</td>
<td>${sizeBytes.toLocaleString()} B</td> </tr>
</tr> <tr class="payload-row">
<tr class="payload-row"> <td colspan="5" class="payload-cell">${safePayload}</td>
<td colspan="6" class="payload-cell">${safePayload}</td> </tr>`);
</tr>`);
} }
} }
/* ====================================================== /* ======================================================
TELEMETRY CHARTS (portnum=67) TELEMETRY CHARTS (portnum=67)
====================================================== */ ====================================================== */
@@ -972,69 +969,45 @@ 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) NEIGHBOR CHART (portnum=71)
====================================================== */ ====================================================== */
async function loadNeighborTimeSeries() { 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 url = `/api/packets?portnum=71&from_node_id=${fromNodeId}&limit=500`;
const res = await fetch(url); const res = await fetch(url);
if (!res.ok) { if (!res.ok) {
container.style.display = "none"; document.getElementById("neighbor_chart_container").style.display = "none";
return; return;
} }
const data = await res.json(); const data = await res.json();
const packets = data.packets || []; let packets = data.packets || [];
if (!packets.length) { if (!packets.length) {
container.style.display = "none"; document.getElementById("neighbor_chart_container").style.display = "none";
return; return;
} }
// Sort packets chronologically (microseconds)
packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0)); packets.sort((a, b) => (a.import_time_us || 0) - (b.import_time_us || 0));
const neighborHistory = {}; // node_id -> { name, times[], snr[] } // neighborHistory = { node_id: { name, snr:[...], times:[...] } }
const neighborHistory = {};
for (const pkt of packets) { for (const pkt of packets) {
if (!pkt.import_time_us || !pkt.payload) continue; if (!pkt.import_time_us || !pkt.payload) continue;
const ts = pkt.import_time_us; // KEEP NUMERIC TIMESTAMP const ts = new Date(pkt.import_time_us / 1000).toLocaleString([], {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit"
});
// Extract neighbor blocks
const blockRe = /neighbors\s*\{([^}]+)\}/g; const blockRe = /neighbors\s*\{([^}]+)\}/g;
let m; let m;
while ((m = blockRe.exec(pkt.payload)) !== null) { while ((m = blockRe.exec(pkt.payload)) !== null) {
const block = m[1]; const block = m[1];
@@ -1046,14 +1019,9 @@ async function loadNeighborTimeSeries() {
const nid = parseInt(idMatch[1], 10); const nid = parseInt(idMatch[1], 10);
const snr = parseFloat(snrMatch[1]); const snr = parseFloat(snrMatch[1]);
// Fetch neighbor metadata once
const neighbor = await fetchNodeFromApi(nid);
if (!neighborHistory[nid]) { if (!neighborHistory[nid]) {
neighborHistory[nid] = { neighborHistory[nid] = {
name: neighbor?.short_name || name: nodeMap[nid] || `Node ${nid}`,
neighbor?.long_name ||
`Node ${nid}`,
times: [], times: [],
snr: [] snr: []
}; };
@@ -1064,59 +1032,45 @@ async function loadNeighborTimeSeries() {
} }
} }
// Collect ALL timestamps across neighbors const chart = echarts.init(document.getElementById("chart_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 legend = [];
const series = []; const series = [];
for (const entry of Object.values(neighborHistory)) { for (const [nid, entry] of Object.entries(neighborHistory)) {
legend.push(entry.name); legend.push(entry.name);
series.push({ series.push({
name: entry.name, name: entry.name,
type: "line", type: "line",
smooth: true, smooth: true,
connectNulls: true, connectNulls: true, // --- FIX #2: connect dots even if missing ---
showSymbol: false, showSymbol: false,
data: xTimes.map(t => { data: entry.snr,
const idx = entry.times.indexOf(t);
return idx >= 0 ? entry.snr[idx] : null;
})
}); });
} }
const chart = echarts.init(chartEl); // 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);
});
chart.setOption({ chart.setOption({
tooltip: { tooltip: { trigger: "axis" },
trigger: "axis", legend: { data: legend, textStyle: { color: "#ccc" } },
axisPointer: { type: "line" }
},
legend: {
data: legend,
textStyle: { color: "#ccc" }
},
xAxis: { xAxis: {
type: "category", type: "category",
data: xTimes, data: sampleTimes,
axisLabel: { axisLabel: { color: "#ccc" }
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: { yAxis: {
type: "value", type: "value",
@@ -1130,7 +1084,6 @@ async function loadNeighborTimeSeries() {
} }
async function loadPacketHistogram() { async function loadPacketHistogram() {
const DAYS = 7; const DAYS = 7;
const now = new Date(); const now = new Date();
@@ -1304,29 +1257,17 @@ document.addEventListener("click", e => {
====================================================== */ ====================================================== */
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
await loadTranslationsNode(); await loadTranslationsNode(); // translations first
requestAnimationFrame(async () => { requestAnimationFrame(async () => {
await loadNodeInfo(); await loadNodeInfo(); // single-node fetch
if (!map) initMap(); // init map early so neighbors can draw
// ✅ 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 loadTrack();
await loadPackets(); await loadPackets();
initPacketPortFilter(); initPacketPortFilter();
await loadTelemetryCharts(); await loadTelemetryCharts();
await loadNeighborTimeSeries(); await loadNeighborTimeSeries();
await loadPacketHistogram(); await loadPacketHistogram();
ensureMapVisible(); ensureMapVisible();
setTimeout(ensureMapVisible, 1000); setTimeout(ensureMapVisible, 1000);
window.addEventListener("resize", ensureMapVisible); window.addEventListener("resize", ensureMapVisible);
@@ -1334,25 +1275,6 @@ 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) { async function loadNodeStats(nodeId) {
try { try {
const res = await fetch( const res = await fetch(
+98 -239
View File
@@ -26,6 +26,8 @@ table {
width: max-content; /* table keeps its natural width */ width: max-content; /* table keeps its natural width */
min-width: 100%; /* won't shrink smaller than viewport */ min-width: 100%; /* won't shrink smaller than viewport */
} }
th, td { th, td {
padding: 10px; padding: 10px;
border: 1px solid #333; border: 1px solid #333;
@@ -103,21 +105,6 @@ select, .export-btn, .search-box, .clear-btn {
font-weight: bold; font-weight: bold;
color: white; 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 stars */
.favorite-star { .favorite-star {
@@ -203,6 +190,7 @@ select, .export-btn, .search-box, .clear-btn {
font-size: 1.4em; font-size: 1.4em;
} }
} }
</style> </style>
{% endblock %} {% endblock %}
@@ -214,7 +202,7 @@ select, .export-btn, .search-box, .clear-btn {
id="search-box" id="search-box"
class="search-box" class="search-box"
data-translate-lang="search_placeholder" data-translate-lang="search_placeholder"
placeholder="Search by name or ID or HEX ID..." placeholder="Search by name or ID..."
/> />
<select id="role-filter"> <select id="role-filter">
@@ -250,7 +238,6 @@ select, .export-btn, .search-box, .clear-btn {
<span data-translate-lang="showing_nodes">Showing</span> <span data-translate-lang="showing_nodes">Showing</span>
<span id="node-count">0</span> <span id="node-count">0</span>
<span data-translate-lang="nodes_suffix">nodes</span> <span data-translate-lang="nodes_suffix">nodes</span>
<span id="node-status" class="node-status" aria-live="polite"></span>
</div> </div>
<!-- Desktop table --> <!-- Desktop table -->
@@ -321,11 +308,6 @@ let allNodes = [];
let sortColumn = "short_name"; let sortColumn = "short_name";
let sortAsc = true; let sortAsc = true;
let showOnlyFavorites = false; 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 headers = document.querySelectorAll("thead th");
const keyMap = [ const keyMap = [
@@ -333,51 +315,28 @@ const keyMap = [
"last_lat","last_long","channel","last_seen_us" "last_lat","last_long","channel","last_seen_us"
]; ];
function debounce(fn, delay = 250) { function getFavorites() {
let t;
return (...args) => {
clearTimeout(t);
t = setTimeout(() => fn(...args), delay);
};
}
function nextFrame() {
return new Promise(resolve => requestAnimationFrame(() => resolve()));
}
function loadFavorites() {
const favorites = localStorage.getItem('nodelist_favorites'); const favorites = localStorage.getItem('nodelist_favorites');
if (!favorites) { return favorites ? JSON.parse(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() { function saveFavorites(favs) {
localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet])); localStorage.setItem('nodelist_favorites', JSON.stringify(favs));
} }
function toggleFavorite(nodeId) { function toggleFavorite(nodeId) {
if (favoritesSet.has(nodeId)) { let favs = getFavorites();
favoritesSet.delete(nodeId); const idx = favs.indexOf(nodeId);
} else { if (idx >= 0) favs.splice(idx, 1);
favoritesSet.add(nodeId); else favs.push(nodeId);
} saveFavorites(favs);
saveFavorites();
} }
function isFavorite(nodeId) { function isFavorite(nodeId) {
return favoritesSet.has(nodeId); return getFavorites().includes(nodeId);
} }
function timeAgoFromMs(msTimestamp) { function timeAgo(usTimestamp) {
if (!msTimestamp) return "N/A"; if (!usTimestamp) return "N/A";
const diff = Date.now() - msTimestamp; const ms = usTimestamp / 1000;
const diff = Date.now() - ms;
if (diff < 60000) return "just now"; if (diff < 60000) return "just now";
const mins = Math.floor(diff / 60000); const mins = Math.floor(diff / 60000);
@@ -394,7 +353,6 @@ function timeAgoFromMs(msTimestamp) {
document.addEventListener("DOMContentLoaded", async function() { document.addEventListener("DOMContentLoaded", async function() {
await loadTranslationsNodelist(); await loadTranslationsNodelist();
loadFavorites();
const tbody = document.getElementById("node-table-body"); const tbody = document.getElementById("node-table-body");
const mobileList = document.getElementById("mobile-node-list"); const mobileList = document.getElementById("mobile-node-list");
@@ -405,82 +363,52 @@ document.addEventListener("DOMContentLoaded", async function() {
const firmwareFilter = document.getElementById("firmware-filter"); const firmwareFilter = document.getElementById("firmware-filter");
const searchBox = document.getElementById("search-box"); const searchBox = document.getElementById("search-box");
const countSpan = document.getElementById("node-count"); const countSpan = document.getElementById("node-count");
const statusSpan = document.getElementById("node-status");
const exportBtn = document.getElementById("export-btn"); const exportBtn = document.getElementById("export-btn");
const clearBtn = document.getElementById("clear-btn"); const clearBtn = document.getElementById("clear-btn");
const favoritesBtn = document.getElementById("favorites-btn"); const favoritesBtn = document.getElementById("favorites-btn");
let lastIsMobile = (window.innerWidth <= 768);
try { try {
setStatus("Loading nodes…");
await nextFrame();
const res = await fetch("/api/nodes?days_active=3"); const res = await fetch("/api/nodes?days_active=3");
if (!res.ok) throw new Error("Failed to fetch nodes"); if (!res.ok) throw new Error("Failed to fetch nodes");
const data = await res.json(); const data = await res.json();
allNodes = data.nodes.map(n => ({
allNodes = data.nodes.map(n => { ...n,
const firmware = n.firmware || n.firmware_version || ""; 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); populateFilters(allNodes);
applyFilters(); // ensures initial sort + render uses same path renderTable(allNodes);
updateSortIcons(); updateSortIcons();
setStatus("");
} catch (err) { } catch (err) {
tbody.innerHTML = `<tr> tbody.innerHTML = `<tr>
<td colspan="10" style="text-align:center; color:red;"> <td colspan="10" style="text-align:center; color:red;">
${nodelistTranslations.error_loading_nodes || "Error loading nodes"} ${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
</td></tr>`; </td></tr>`;
setStatus("");
return;
} }
roleFilter.addEventListener("change", applyFilters); roleFilter.addEventListener("change", applyFilters);
channelFilter.addEventListener("change", applyFilters); channelFilter.addEventListener("change", applyFilters);
hwFilter.addEventListener("change", applyFilters); hwFilter.addEventListener("change", applyFilters);
firmwareFilter.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); exportBtn.addEventListener("click", exportToCSV);
clearBtn.addEventListener("click", clearFilters); clearBtn.addEventListener("click", clearFilters);
favoritesBtn.addEventListener("click", toggleFavoritesFilter); favoritesBtn.addEventListener("click", toggleFavoritesFilter);
// Favorite star click handler (delegated) // Favorite star click handler
document.addEventListener("click", e => { document.addEventListener("click", e => {
if (e.target.classList.contains('favorite-star')) { if (e.target.classList.contains('favorite-star')) {
const nodeId = parseInt(e.target.dataset.nodeId, 10); const nodeId = parseInt(e.target.dataset.nodeId);
const fav = isFavorite(nodeId); const isFav = isFavorite(nodeId);
if (fav) { if (isFav) {
e.target.classList.remove("active"); e.target.classList.remove("active");
e.target.textContent = "☆"; e.target.textContent = "☆";
} else { } else {
e.target.classList.add("active"); e.target.classList.add("active");
e.target.textContent = "★"; e.target.textContent = "★";
} }
toggleFavorite(nodeId); toggleFavorite(nodeId);
applyFilters(); applyFilters();
} }
@@ -488,26 +416,13 @@ document.addEventListener("DOMContentLoaded", async function() {
headers.forEach((th, index) => { headers.forEach((th, index) => {
th.addEventListener("click", () => { th.addEventListener("click", () => {
const key = keyMap[index]; let key = keyMap[index];
// ignore clicks on the "favorite" (last header) which has no sort key
if (!key) return;
sortAsc = (sortColumn === key) ? !sortAsc : true; sortAsc = (sortColumn === key) ? !sortAsc : true;
sortColumn = key; sortColumn = key;
applyFilters(); 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) { function populateFilters(nodes) {
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set(); const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
@@ -542,9 +457,7 @@ document.addEventListener("DOMContentLoaded", async function() {
applyFilters(); applyFilters();
} }
async function applyFilters() { function applyFilters() {
setStatus("Updating…");
await nextFrame();
const searchTerm = searchBox.value.trim().toLowerCase(); const searchTerm = searchBox.value.trim().toLowerCase();
let filtered = allNodes.filter(n => { let filtered = allNodes.filter(n => {
@@ -553,116 +466,102 @@ document.addEventListener("DOMContentLoaded", async function() {
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value; const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value;
const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value; const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value;
const searchMatch = !searchTerm || n._search.includes(searchTerm); 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 favMatch = !showOnlyFavorites || isFavorite(n.node_id); const favMatch = !showOnlyFavorites || isFavorite(n.node_id);
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch; return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
}); });
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
filtered = sortNodes(filtered, sortColumn, sortAsc); filtered = sortNodes(filtered, sortColumn, sortAsc);
renderTable(filtered); renderTable(filtered);
updateSortIcons(); updateSortIcons();
setStatus("");
} }
function renderTable(nodes) { function renderTable(nodes) {
tbody.innerHTML = "";
mobileList.innerHTML = "";
const isMobile = window.innerWidth <= 768; 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) { if (!nodes.length) {
if (shouldRenderTable) { tbody.innerHTML = `<tr>
tbody.innerHTML = `<tr> <td colspan="10" style="text-align:center; color:white;">
<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"} ${nodelistTranslations.no_nodes_found || "No nodes found"}
</div>`; </td>
} </tr>`;
mobileList.innerHTML = `<div style="text-align:center; color:white;">No nodes found</div>`;
countSpan.textContent = 0; countSpan.textContent = 0;
return; return;
} }
nodes.forEach(node => { nodes.forEach(node => {
const fav = isFavorite(node.node_id); const isFav = isFavorite(node.node_id);
const star = fav ? "★" : "☆"; const star = isFav ? "★" : "☆";
if (shouldRenderTable) { // DESKTOP TABLE ROW
// DESKTOP TABLE ROW const row = document.createElement("tr");
const row = document.createElement("tr"); row.innerHTML = `
row.innerHTML = ` <td>${node.short_name || "N/A"}</td>
<td>${node.short_name || "N/A"}</td> <td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</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.hw_model || "N/A"}</td> <td>${node.firmware || "N/A"}</td>
<td>${node.firmware || "N/A"}</td> <td>${node.role || "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_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td> <td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td> <td>${node.channel || "N/A"}</td>
<td>${node.channel || "N/A"}</td> <td>${timeAgo(node.last_seen_us)}</td>
<td>${timeAgoFromMs(node.last_seen_ms)}</td> <td style="text-align:center;">
<td style="text-align:center;"> <span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}"> ${star}
${star} </span>
</span> </td>
</td> `;
`; tbody.appendChild(row);
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>
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div> // MOBILE CARD VIEW
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div> const card = document.createElement("div");
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div> card.className = "node-card";
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div> card.innerHTML = `
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div> <div class="node-card-header">
<div class="node-card-field"><b>Location:</b> <span>${node.short_name || node.long_name || node.node_id}</span>
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"}, <span class="favorite-star ${isFav ? "active" : ""}" data-node-id="${node.node_id}">
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"} ${star}
</div> </span>
<div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div> </div>
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;"> <div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
View Node → <div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
</a> <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>
mobileFrag.appendChild(card); <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);
}); });
// Toggle correct view // Toggle correct view
mobileList.style.display = isMobile ? "block" : "none"; if (isMobile) {
mobileList.style.display = "block";
} else {
mobileList.style.display = "none";
}
countSpan.textContent = nodes.length; countSpan.textContent = nodes.length;
if (shouldRenderTable) {
tbody.appendChild(tableFrag);
} else {
mobileList.appendChild(mobileFrag);
}
} }
function clearFilters() { function clearFilters() {
@@ -671,7 +570,6 @@ document.addEventListener("DOMContentLoaded", async function() {
hwFilter.value = ""; hwFilter.value = "";
firmwareFilter.value = ""; firmwareFilter.value = "";
searchBox.value = ""; searchBox.value = "";
sortColumn = "short_name"; sortColumn = "short_name";
sortAsc = true; sortAsc = true;
showOnlyFavorites = false; showOnlyFavorites = false;
@@ -679,7 +577,7 @@ document.addEventListener("DOMContentLoaded", async function() {
favoritesBtn.textContent = "⭐ Show Favorites"; favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active"); favoritesBtn.classList.remove("active");
applyFilters(); renderTable(allNodes);
updateSortIcons(); updateSortIcons();
} }
@@ -715,10 +613,6 @@ document.addEventListener("DOMContentLoaded", async function() {
B = B || 0; 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;
if (A > B) return asc ? 1 : -1; if (A > B) return asc ? 1 : -1;
return 0; return 0;
@@ -733,41 +627,6 @@ document.addEventListener("DOMContentLoaded", async function() {
keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : ""; 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> </script>
{% endblock %} {% endblock %}
+15 -20
View File
@@ -1,10 +1,7 @@
"""Main web server routes and page rendering for Meshview."""
import asyncio import asyncio
import datetime import datetime
import logging import logging
import os import os
import pathlib
import re import re
import ssl import ssl
from dataclasses import dataclass from dataclasses import dataclass
@@ -15,7 +12,7 @@ from google.protobuf import text_format
from google.protobuf.message import Message from google.protobuf.message import Message
from jinja2 import Environment, PackageLoader, Undefined, select_autoescape from jinja2 import Environment, PackageLoader, Undefined, select_autoescape
from markupsafe import Markup from markupsafe import Markup
import pathlib
from meshtastic.protobuf.portnums_pb2 import PortNum from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import config, database, decode_payload, migrations, models, store from meshview import config, database, decode_payload, migrations, models, store
from meshview.__version__ import ( from meshview.__version__ import (
@@ -48,8 +45,6 @@ with open(os.path.join(os.path.dirname(__file__), '1x1.png'), 'rb') as png:
@dataclass @dataclass
class Packet: class Packet:
"""UI-friendly packet wrapper for templates and API payloads."""
id: int id: int
from_node_id: int from_node_id: int
from_node: models.Node from_node: models.Node
@@ -61,11 +56,11 @@ class Packet:
raw_payload: object raw_payload: object
payload: str payload: str
pretty_payload: Markup pretty_payload: Markup
import_time: datetime.datetime
import_time_us: int import_time_us: int
@classmethod @classmethod
def from_model(cls, packet): def from_model(cls, packet):
"""Convert a Packet ORM model into a presentation-friendly Packet."""
mesh_packet, payload = decode_payload.decode(packet) mesh_packet, payload = decode_payload.decode(packet)
pretty_payload = None pretty_payload = None
@@ -106,6 +101,7 @@ class Packet:
data=text_mesh_packet, data=text_mesh_packet,
payload=text_payload, # now always a string payload=text_payload, # now always a string
pretty_payload=pretty_payload, pretty_payload=pretty_payload,
import_time=packet.import_time,
import_time_us=packet.import_time_us, # <-- include microseconds import_time_us=packet.import_time_us, # <-- include microseconds
raw_mesh_packet=mesh_packet, raw_mesh_packet=mesh_packet,
raw_payload=payload, raw_payload=payload,
@@ -113,7 +109,6 @@ class Packet:
async def build_trace(node_id): async def build_trace(node_id):
"""Build a recent GPS trace list for a node using position packets."""
trace = [] trace = []
for raw_p in await store.get_packets_from( for raw_p in await store.get_packets_from(
node_id, PortNum.POSITION_APP, since=datetime.timedelta(hours=24) node_id, PortNum.POSITION_APP, since=datetime.timedelta(hours=24)
@@ -135,7 +130,6 @@ async def build_trace(node_id):
async def build_neighbors(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) packets = await store.get_packets_from(node_id, PortNum.NEIGHBORINFO_APP, limit=1)
packet = packets.first() packet = packets.first()
@@ -165,7 +159,6 @@ async def build_neighbors(node_id):
def node_id_to_hex(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): if node_id is None or isinstance(node_id, Undefined):
return "Invalid node_id" # i... have no clue return "Invalid node_id" # i... have no clue
if node_id == 4294967295: if node_id == 4294967295:
@@ -175,7 +168,6 @@ def node_id_to_hex(node_id):
def format_timestamp(timestamp): def format_timestamp(timestamp):
"""Normalize timestamps to ISO 8601 strings."""
if isinstance(timestamp, int): if isinstance(timestamp, int):
timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.UTC) timestamp = datetime.datetime.fromtimestamp(timestamp, datetime.UTC)
return timestamp.isoformat(timespec="milliseconds") return timestamp.isoformat(timespec="milliseconds")
@@ -193,7 +185,6 @@ routes = web.RouteTableDef()
@routes.get("/") @routes.get("/")
async def index(request): async def index(request):
"""Redirect root URL to configured starting page."""
""" """
Redirect root URL '/' to the page specified in CONFIG['site']['starting']. Redirect root URL '/' to the page specified in CONFIG['site']['starting'].
Defaults to '/map' if not set. Defaults to '/map' if not set.
@@ -203,10 +194,15 @@ async def index(request):
raise web.HTTPFound(location=starting_url) raise web.HTTPFound(location=starting_url)
# redirect for backwards compatibility
@routes.get("/packet_list/{packet_id}")
async def redirect_packet_list(request):
packet_id = request.match_info["packet_id"]
raise web.HTTPFound(location=f"/node/{packet_id}")
# Generic static HTML route # Generic static HTML route
@routes.get("/{page}") @routes.get("/{page}")
async def serve_page(request): async def serve_page(request):
"""Serve static HTML pages from meshview/static."""
page = request.match_info["page"] page = request.match_info["page"]
# default to index.html if no extension # default to index.html if no extension
@@ -221,6 +217,7 @@ async def serve_page(request):
return web.Response(text=content, content_type="text/html") return web.Response(text=content, content_type="text/html")
@routes.get("/net") @routes.get("/net")
async def net(request): async def net(request):
return web.Response( return web.Response(
@@ -355,8 +352,8 @@ async def graph_traceroute(request):
# It seems some nodes add them self to the list before uplinking # It seems some nodes add them self to the list before uplinking
path.append(tr.gateway_node_id) path.append(tr.gateway_node_id)
if not tr.done and tr.gateway_node_id not in node_seen_time and tr.import_time_us: 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_us node_seen_time[path[-1]] = tr.import_time
mqtt_nodes.add(tr.gateway_node_id) mqtt_nodes.add(tr.gateway_node_id)
node_color[path[-1]] = '#' + hex(hash(tuple(path)))[3:9] node_color[path[-1]] = '#' + hex(hash(tuple(path)))[3:9]
@@ -366,7 +363,7 @@ async def graph_traceroute(request):
for path in paths: for path in paths:
used_nodes.update(path) used_nodes.update(path)
import_times = [tr.import_time_us for tr in traceroutes if tr.import_time_us] import_times = [tr.import_time for tr in traceroutes if tr.import_time]
if import_times: if import_times:
first_time = min(import_times) first_time = min(import_times)
else: else:
@@ -381,7 +378,7 @@ async def graph_traceroute(request):
f'[{node.short_name}] {node.long_name}\n{node_id_to_hex(node_id)}\n{node.role}' f'[{node.short_name}] {node.long_name}\n{node_id_to_hex(node_id)}\n{node.role}'
) )
if node_id in node_seen_time: if node_id in node_seen_time:
ms = (node_seen_time[node_id] - first_time) / 1000 ms = (node_seen_time[node_id] - first_time).total_seconds() * 1000
node_name += f'\n {ms:.2f}ms' node_name += f'\n {ms:.2f}ms'
style = 'dashed' style = 'dashed'
if node_id == dest: if node_id == dest:
@@ -399,7 +396,7 @@ async def graph_traceroute(request):
shape='box', shape='box',
color=node_color.get(node_id, 'black'), color=node_color.get(node_id, 'black'),
style=style, style=style,
href=f"/node/{node_id}", href=f"/packet_list/{node_id}",
) )
) )
@@ -415,7 +412,6 @@ async def graph_traceroute(request):
async def run_server(): async def run_server():
"""Start the aiohttp web server after migrations are complete."""
# Wait for database migrations to complete before starting web server # Wait for database migrations to complete before starting web server
logger.info("Checking database schema status...") logger.info("Checking database schema status...")
database_url = CONFIG["database"]["connection_string"] database_url = CONFIG["database"]["connection_string"]
@@ -432,7 +428,6 @@ async def run_server():
logger.info("Database schema verified - starting web server") logger.info("Database schema verified - starting web server")
app = web.Application() app = web.Application()
app.router.add_static("/static/", pathlib.Path(__file__).parent / "static")
app.add_routes(api.routes) # Add API routes app.add_routes(api.routes) # Add API routes
app.add_routes(routes) # Add main web routes app.add_routes(routes) # Add main web routes
+125 -95
View File
@@ -6,15 +6,12 @@ import logging
import os import os
from aiohttp import web from aiohttp import web
from sqlalchemy import func, select, text from sqlalchemy import text
from meshtastic.protobuf.portnums_pb2 import PortNum from meshtastic.protobuf.portnums_pb2 import PortNum
from meshview import database, decode_payload, store from meshview import database, decode_payload, store
from meshview.__version__ import __version__, _git_revision_short, get_version_info from meshview.__version__ import __version__, _git_revision_short, get_version_info
from meshview.config import CONFIG 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__) logger = logging.getLogger(__name__)
@@ -129,6 +126,7 @@ async def api_packets(request):
"portnum": int(p.portnum) if p.portnum is not None else None, "portnum": int(p.portnum) if p.portnum is not None else None,
"payload": (p.payload or "").strip(), "payload": (p.payload or "").strip(),
"import_time_us": p.import_time_us, "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", ""), "channel": getattr(p.from_node, "channel", ""),
"long_name": getattr(p.from_node, "long_name", ""), "long_name": getattr(p.from_node, "long_name", ""),
} }
@@ -210,6 +208,7 @@ async def api_packets(request):
packet_dict = { packet_dict = {
"id": p.id, "id": p.id,
"import_time_us": p.import_time_us, "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", ""), "channel": getattr(p.from_node, "channel", ""),
"from_node_id": p.from_node_id, "from_node_id": p.from_node_id,
"to_node_id": p.to_node_id, "to_node_id": p.to_node_id,
@@ -229,12 +228,20 @@ async def api_packets(request):
packets_data.append(packet_dict) packets_data.append(packet_dict)
# --- Latest import_time_us for incremental fetch --- # --- Latest import_time for incremental fetch ---
latest_import_time = None latest_import_time = None
if packets_data: if packets_data:
for p in packets_data: for p in packets_data:
if p.get("import_time_us") and p["import_time_us"] > 0: 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"]) 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} response = {"packets": packets_data}
if latest_import_time is not None: if latest_import_time is not None:
@@ -424,10 +431,14 @@ async def api_edges(request):
try: try:
node_filter = int(node_filter_str) node_filter = int(node_filter_str)
except ValueError: 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 = {} edges = {}
traceroute_count = 0 traceroute_count = 0
neighbor_packet_count = 0
edges_added_tr = 0 edges_added_tr = 0
edges_added_neighbor = 0 edges_added_neighbor = 0
@@ -452,6 +463,8 @@ async def api_edges(request):
# --- Neighbor edges --- # --- Neighbor edges ---
if filter_type in (None, "neighbor"): if filter_type in (None, "neighbor"):
packets = await store.get_packets(portnum=71) packets = await store.get_packets(portnum=71)
neighbor_packet_count = len(packets)
for packet in packets: for packet in packets:
try: try:
_, neighbor_info = decode_payload.decode(packet) _, neighbor_info = decode_payload.decode(packet)
@@ -466,16 +479,21 @@ async def api_edges(request):
# Convert to list # Convert to list
edges_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 # NEW → apply node_id filtering
if node_filter is not None: 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}) return web.json_response({"edges": edges_list})
@routes.get("/api/config") @routes.get("/api/config")
async def api_config(request): async def api_config(request):
try: try:
@@ -693,6 +711,7 @@ async def api_packets_seen(request):
"rx_snr": row.rx_snr, "rx_snr": row.rx_snr,
"rx_rssi": row.rx_rssi, "rx_rssi": row.rx_rssi,
"topic": row.topic, "topic": row.topic,
"import_time": (row.import_time.isoformat() if row.import_time else None),
"import_time_us": row.import_time_us, "import_time_us": row.import_time_us,
} }
) )
@@ -706,7 +725,6 @@ async def api_packets_seen(request):
status=500, status=500,
) )
@routes.get("/api/traceroute/{packet_id}") @routes.get("/api/traceroute/{packet_id}")
async def api_traceroute(request): async def api_traceroute(request):
packet_id = int(request.match_info['packet_id']) packet_id = int(request.match_info['packet_id'])
@@ -728,15 +746,14 @@ async def api_traceroute(request):
forward_list = list(route.route) forward_list = list(route.route)
reverse_list = list(route.route_back) reverse_list = list(route.route_back)
tr_groups.append( tr_groups.append({
{ "index": idx,
"index": idx, "import_time": tr.import_time.isoformat() if tr.import_time else None,
"gateway_node_id": tr.gateway_node_id, "gateway_node_id": tr.gateway_node_id,
"done": tr.done, "done": tr.done,
"forward_hops": forward_list, "forward_hops": forward_list,
"reverse_hops": reverse_list, "reverse_hops": reverse_list,
} })
)
# -------------------------------------------- # --------------------------------------------
# Compute UNIQUE paths + counts + winning path # Compute UNIQUE paths + counts + winning path
@@ -779,20 +796,18 @@ async def api_traceroute(request):
# -------------------------------------------- # --------------------------------------------
# Final API output # Final API output
# -------------------------------------------- # --------------------------------------------
return web.json_response( return web.json_response({
{ "packet": {
"packet": { "id": packet.id,
"id": packet.id, "from": packet.from_node_id,
"from": packet.from_node_id, "to": packet.to_node_id,
"to": packet.to_node_id, "channel": packet.channel,
"channel": packet.channel, },
}, "traceroute_packets": tr_groups,
"traceroute_packets": tr_groups, "unique_forward_paths": unique_forward_paths_json,
"unique_forward_paths": unique_forward_paths_json, "unique_reverse_paths": unique_reverse_paths_json,
"unique_reverse_paths": unique_reverse_paths_json, "winning_paths": winning_paths_json,
"winning_paths": winning_paths_json, })
}
)
@routes.get("/api/stats/top") @routes.get("/api/stats/top")
@@ -808,75 +823,90 @@ async def api_stats_top(request):
limit = min(int(request.query.get("limit", 20)), 100) limit = min(int(request.query.get("limit", 20)), 100)
offset = int(request.query.get("offset", 0)) offset = int(request.query.get("offset", 0))
multiplier = 3600 if period_type == "hour" else 86400 params = {
window_us = length * multiplier * 1_000_000 "period_type": period_type,
"length": length,
max_packet_import = select(func.max(PacketModel.import_time_us)).scalar_subquery() "limit": limit,
max_seen_import = select(func.max(PacketSeenModel.import_time_us)).scalar_subquery() "offset": offset,
}
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)
channel_filter = ""
if channel: if channel:
query = query.where(Node.channel == channel) channel_filter = "AND n.channel = :channel"
count_query = count_query.where(Node.channel == channel) params["channel"] = channel
sql = f"""
WITH sent AS (
SELECT
p.from_node_id AS node_id,
COUNT(*) AS sent
FROM packet p
WHERE p.import_time_us >= (
SELECT MAX(import_time_us) FROM packet
) - (
CASE
WHEN :period_type = 'hour' THEN :length * 3600 * 1000000
ELSE :length * 86400 * 1000000
END
)
GROUP BY p.from_node_id
),
seen AS (
SELECT
p.from_node_id AS node_id,
COUNT(*) AS seen
FROM packet_seen ps
JOIN packet p ON p.id = ps.packet_id
WHERE ps.import_time_us >= (
SELECT MAX(import_time_us) FROM packet_seen
) - (
CASE
WHEN :period_type = 'hour' THEN :length * 3600 * 1000000
ELSE :length * 86400 * 1000000
END
)
GROUP BY p.from_node_id
)
SELECT
n.node_id,
n.long_name,
n.short_name,
n.channel,
COALESCE(s.sent, 0) AS sent,
COALESCE(se.seen, 0) AS seen
FROM node n
LEFT JOIN sent s ON s.node_id = n.node_id
LEFT JOIN seen se ON se.node_id = n.node_id
WHERE 1=1
{channel_filter}
ORDER BY seen DESC
LIMIT :limit OFFSET :offset
"""
count_sql = f"""
SELECT COUNT(*) FROM node n WHERE 1=1 {channel_filter}
"""
async with database.async_session() as session: async with database.async_session() as session:
rows = (await session.execute(query)).all() rows = (await session.execute(text(sql), params)).all()
total = (await session.execute(count_query)).scalar() or 0 total = (await session.execute(text(count_sql), params)).scalar() or 0
nodes = [] nodes = []
for r in rows: for r in rows:
avg = r.seen / max(r.sent, 1) avg = r.seen / max(r.sent, 1)
nodes.append( nodes.append({
{ "node_id": r.node_id,
"node_id": r.node_id, "long_name": r.long_name,
"long_name": r.long_name, "short_name": r.short_name,
"short_name": r.short_name, "channel": r.channel,
"channel": r.channel, "sent": r.sent,
"sent": r.sent, "seen": r.seen,
"seen": r.seen, "avg": round(avg, 2),
"avg": round(avg, 2), })
}
)
return web.json_response( return web.json_response({
{ "total": total,
"total": total, "limit": limit,
"limit": limit, "offset": offset,
"offset": offset, "nodes": nodes,
"nodes": nodes, })
}
)
+1 -4
View File
@@ -81,10 +81,7 @@ password = large4cats
# Database Configuration # Database Configuration
# ------------------------- # -------------------------
[database] [database]
# SQLAlchemy async connection string. # SQLAlchemy connection string. This one uses SQLite with asyncio support.
# Examples:
# sqlite+aiosqlite:///packets.db
# postgresql+asyncpg://user:pass@host:5432/meshview
connection_string = sqlite+aiosqlite:///packets.db connection_string = sqlite+aiosqlite:///packets.db
+20
View File
@@ -0,0 +1,20 @@
# Basic Jinja Project
Small starter project that renders HTML with Jinja using Python's built-in HTTP server.
## Run
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python3 collector.py
```
Open `http://127.0.0.1:8000/`.
## Files
- `collector.py` starts the server and renders templates.
- `templates/base.html` is the shared layout.
- `templates/index.html` is the page template.
+274
View File
@@ -0,0 +1,274 @@
#!/usr/bin/env python3
import argparse
import json
import sqlite3
from datetime import datetime, timezone
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from jinja2 import Environment, FileSystemLoader, select_autoescape
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
DEFAULT_DB_PATH = "readings.db"
DEFAULT_READINGS_LIMIT = 100
BASE_DIR = Path(__file__).resolve().parent
TEMPLATE_DIR = BASE_DIR / "templates"
templates = Environment(
loader=FileSystemLoader(TEMPLATE_DIR),
autoescape=select_autoescape(["html", "xml"]),
)
def utc_now_iso():
return datetime.now(timezone.utc).isoformat()
def init_db(db_path):
conn = sqlite3.connect(db_path)
try:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS readings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
sensor_type TEXT NOT NULL,
temperature_c REAL NOT NULL,
humidity REAL NOT NULL,
received_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_readings_device_received_at
ON readings (device_id, received_at DESC)
"""
)
conn.commit()
finally:
conn.close()
def insert_reading(db_path, reading):
conn = sqlite3.connect(db_path)
try:
cursor = conn.execute(
"""
INSERT INTO readings (
device_id,
sensor_type,
temperature_c,
humidity,
received_at
) VALUES (?, ?, ?, ?, ?)
""",
(
reading["device_id"],
reading["sensor_type"],
reading["temperature_c"],
reading["humidity"],
utc_now_iso(),
),
)
conn.commit()
return cursor.lastrowid
finally:
conn.close()
def fetch_recent_readings(db_path, limit):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
rows = conn.execute(
"""
SELECT
id,
device_id,
sensor_type,
temperature_c,
humidity,
received_at
FROM readings
ORDER BY received_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
readings = [dict(row) for row in rows]
readings.reverse()
return readings
finally:
conn.close()
def validate_payload(payload):
required_fields = (
"device_id",
"sensor_type",
"temperature_c",
"humidity",
)
for field in required_fields:
if field not in payload:
raise ValueError("Missing field: {}".format(field))
validated = {
"device_id": str(payload["device_id"]).strip(),
"sensor_type": str(payload["sensor_type"]).strip(),
"temperature_c": float(payload["temperature_c"]),
"humidity": float(payload["humidity"]),
}
if not validated["device_id"]:
raise ValueError("device_id must not be empty")
if not validated["sensor_type"]:
raise ValueError("sensor_type must not be empty")
return validated
def render_template(template_name, **context):
template = templates.get_template(template_name)
return template.render(**context)
def build_dashboard_context(db_path):
readings = fetch_recent_readings(db_path, DEFAULT_READINGS_LIMIT)
latest = readings[-1] if readings else None
return {
"title": "ESP32 Collector",
"readings": readings,
"reading_count": len(readings),
"latest": latest,
}
class AppHandler(BaseHTTPRequestHandler):
server_version = "ESP32Collector/1.0"
def send_json(self, payload, status=HTTPStatus.OK):
body = json.dumps(payload).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def send_html(self, html, status=HTTPStatus.OK):
body = html.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
route = urlparse(self.path).path
if route == "/":
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
return
if route == "/chart":
self.send_html(render_template("dashboard.html", **build_dashboard_context(self.server.db_path)))
return
if route == "/health":
self.send_json({"status": "ok"})
return
if route == "/readings":
readings = fetch_recent_readings(self.server.db_path, DEFAULT_READINGS_LIMIT)
self.send_json({"readings": readings})
return
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
def do_POST(self):
route = urlparse(self.path).path
if route != "/metrics":
self.send_json({"error": "not found"}, status=HTTPStatus.NOT_FOUND)
return
content_length = self.headers.get("Content-Length")
if not content_length:
self.send_json({"error": "missing content length"}, status=HTTPStatus.LENGTH_REQUIRED)
return
try:
raw_body = self.rfile.read(int(content_length))
payload = json.loads(raw_body)
reading = validate_payload(payload)
row_id = insert_reading(self.server.db_path, reading)
except ValueError as exc:
self.send_json({"error": str(exc)}, status=HTTPStatus.BAD_REQUEST)
return
except json.JSONDecodeError:
self.send_json({"error": "invalid json"}, status=HTTPStatus.BAD_REQUEST)
return
except Exception as exc:
self.send_json(
{"error": "server error", "detail": str(exc)},
status=HTTPStatus.INTERNAL_SERVER_ERROR,
)
return
print(
"[{}] stored reading id={} device={} temp_c={} humidity={}".format(
utc_now_iso(),
row_id,
reading["device_id"],
reading["temperature_c"],
reading["humidity"],
)
)
self.send_json({"status": "stored", "id": row_id}, status=HTTPStatus.CREATED)
def log_message(self, format_string, *args):
print(
"[{}] {} {}".format(
utc_now_iso(),
self.address_string(),
format_string % args,
)
)
def parse_args():
parser = argparse.ArgumentParser(
description="Collect ESP32 sensor data and render a Jinja dashboard."
)
parser.add_argument("--host", default=DEFAULT_HOST)
parser.add_argument("--port", type=int, default=DEFAULT_PORT)
parser.add_argument("--db", default=DEFAULT_DB_PATH)
return parser.parse_args()
def main():
args = parse_args()
db_path = Path(args.db).resolve()
init_db(db_path)
server = ThreadingHTTPServer((args.host, args.port), AppHandler)
server.db_path = str(db_path)
print("Metrics endpoint: http://{}:{}/metrics".format(args.host, args.port))
print("Dashboard: http://{}:{}/chart".format(args.host, args.port))
print("SQLite database:", db_path)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down")
finally:
server.server_close()
if __name__ == "__main__":
main()
+1
View File
@@ -0,0 +1 @@
Jinja2>=3.1,<4
+123
View File
@@ -0,0 +1,123 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }}</title>
<style>
:root {
--bg: #f8f5ef;
--panel: #fffdf8;
--text: #1f2937;
--accent: #0f766e;
--border: #d6d3d1;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: Georgia, serif;
color: var(--text);
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.10), transparent 25%),
linear-gradient(180deg, #fdfcf9 0%, var(--bg) 100%);
}
main {
width: min(760px, calc(100% - 32px));
margin: 48px auto;
}
.card {
padding: 32px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: 0 20px 50px rgba(31, 41, 55, 0.08);
}
h1 {
margin: 0 0 12px;
font-size: clamp(2rem, 5vw, 3rem);
}
p {
line-height: 1.6;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin: 24px 0;
}
.stat {
padding: 14px 16px;
border: 1px solid var(--border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
}
.stat strong,
.stat span {
display: block;
}
.stat strong {
font-size: 1.2rem;
}
.stat span {
color: #57534e;
margin-top: 4px;
}
.chart-wrap {
position: relative;
height: 360px;
margin: 24px 0;
}
.empty {
margin-top: 24px;
padding: 18px;
border: 1px dashed var(--border);
border-radius: 16px;
background: rgba(255, 255, 255, 0.7);
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 24px;
font-size: 0.95rem;
}
th,
td {
text-align: left;
padding: 10px 8px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
code {
font-family: "Courier New", monospace;
background: rgba(15, 118, 110, 0.08);
padding: 2px 6px;
border-radius: 6px;
}
</style>
</head>
<body>
<main>
<section class="card">
{% block content %}{% endblock %}
</section>
</main>
</body>
</html>
+99
View File
@@ -0,0 +1,99 @@
{% extends "base.html" %}
{% block content %}
<h1>ESP32 Collector</h1>
<p>Recent sensor uploads rendered with Jinja.</p>
<div class="stats">
<div class="stat">
<strong>{{ reading_count }}</strong>
<span>stored readings</span>
</div>
<div class="stat">
<strong>{{ latest.device_id if latest else "n/a" }}</strong>
<span>latest device</span>
</div>
<div class="stat">
<strong>{{ "%.1f"|format(latest.temperature_c) if latest else "n/a" }}</strong>
<span>latest temp C</span>
</div>
<div class="stat">
<strong>{{ "%.1f"|format(latest.humidity) if latest else "n/a" }}</strong>
<span>latest humidity %</span>
</div>
</div>
{% if readings %}
<div class="chart-wrap">
<canvas id="readingChart"></canvas>
</div>
<table>
<thead>
<tr>
<th>Time</th>
<th>Device</th>
<th>Sensor</th>
<th>Temp C</th>
<th>Humidity %</th>
</tr>
</thead>
<tbody>
{% for reading in readings|reverse %}
<tr>
<td>{{ reading.received_at }}</td>
<td>{{ reading.device_id }}</td>
<td>{{ reading.sensor_type }}</td>
<td>{{ "%.1f"|format(reading.temperature_c) }}</td>
<td>{{ "%.1f"|format(reading.humidity) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
const readings = {{ readings | tojson }};
const labels = readings.map((reading) => new Date(reading.received_at).toLocaleString());
const temperatures = readings.map((reading) => reading.temperature_c);
const humidity = readings.map((reading) => reading.humidity);
new Chart(document.getElementById("readingChart"), {
type: "line",
data: {
labels,
datasets: [
{
label: "Temperature (C)",
data: temperatures,
borderColor: "#c2410c",
backgroundColor: "rgba(194, 65, 12, 0.14)",
borderWidth: 3,
fill: true,
tension: 0.25,
pointRadius: 2
},
{
label: "Humidity (%)",
data: humidity,
borderColor: "#0f766e",
backgroundColor: "rgba(15, 118, 110, 0.12)",
borderWidth: 3,
fill: true,
tension: 0.25,
pointRadius: 2
}
]
},
options: {
responsive: true,
maintainAspectRatio: false
}
});
</script>
{% else %}
<div class="empty">
No readings yet. Your ESP32 should post JSON to <code>/metrics</code>.
</div>
{% endif %}
{% endblock %}
+16 -21
View File
@@ -7,7 +7,6 @@ import shutil
from pathlib import Path from pathlib import Path
from sqlalchemy import delete from sqlalchemy import delete
from sqlalchemy.engine.url import make_url
from meshview import migrations, models, mqtt_database, mqtt_reader, mqtt_store from meshview import migrations, models, mqtt_database, mqtt_reader, mqtt_store
from meshview.config import CONFIG from meshview.config import CONFIG
@@ -66,16 +65,18 @@ async def backup_database(database_url: str, backup_dir: str = ".") -> None:
backup_dir: Directory to store backups (default: current directory) backup_dir: Directory to store backups (default: current directory)
""" """
try: try:
url = make_url(database_url) # Extract database file path from connection string
if not url.drivername.startswith("sqlite"): # Format: sqlite+aiosqlite:///path/to/db.db
if not database_url.startswith("sqlite"):
cleanup_logger.warning("Backup only supported for SQLite databases") cleanup_logger.warning("Backup only supported for SQLite databases")
return return
if not url.database or url.database == ":memory:": db_path = database_url.split("///", 1)[1] if "///" in database_url else None
if not db_path:
cleanup_logger.error("Could not extract database path from connection string") cleanup_logger.error("Could not extract database path from connection string")
return return
db_file = Path(url.database) db_file = Path(db_path)
if not db_file.exists(): if not db_file.exists():
cleanup_logger.error(f"Database file not found: {db_file}") cleanup_logger.error(f"Database file not found: {db_file}")
return return
@@ -152,11 +153,11 @@ async def daily_cleanup_at(
cleanup_logger.info("Waiting 60 seconds for backup to complete...") cleanup_logger.info("Waiting 60 seconds for backup to complete...")
await asyncio.sleep(60) await asyncio.sleep(60)
cutoff_dt = ( # Local-time cutoff as string for SQLite DATETIME comparison
datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=days_to_keep) cutoff = (datetime.datetime.now() - datetime.timedelta(days=days_to_keep)).strftime(
).replace(tzinfo=None) "%Y-%m-%d %H:%M:%S"
cutoff_us = int(cutoff_dt.timestamp() * 1_000_000) )
cleanup_logger.info(f"Running cleanup for records older than {cutoff_dt.isoformat()}...") cleanup_logger.info(f"Running cleanup for records older than {cutoff}...")
try: try:
async with db_lock: # Pause ingestion async with db_lock: # Pause ingestion
@@ -167,7 +168,7 @@ async def daily_cleanup_at(
# Packet # Packet
# ------------------------- # -------------------------
result = await session.execute( result = await session.execute(
delete(models.Packet).where(models.Packet.import_time_us < cutoff_us) delete(models.Packet).where(models.Packet.import_time < cutoff)
) )
cleanup_logger.info(f"Deleted {result.rowcount} rows from Packet") cleanup_logger.info(f"Deleted {result.rowcount} rows from Packet")
@@ -175,9 +176,7 @@ async def daily_cleanup_at(
# PacketSeen # PacketSeen
# ------------------------- # -------------------------
result = await session.execute( result = await session.execute(
delete(models.PacketSeen).where( delete(models.PacketSeen).where(models.PacketSeen.import_time < cutoff)
models.PacketSeen.import_time_us < cutoff_us
)
) )
cleanup_logger.info(f"Deleted {result.rowcount} rows from PacketSeen") cleanup_logger.info(f"Deleted {result.rowcount} rows from PacketSeen")
@@ -185,9 +184,7 @@ async def daily_cleanup_at(
# Traceroute # Traceroute
# ------------------------- # -------------------------
result = await session.execute( result = await session.execute(
delete(models.Traceroute).where( delete(models.Traceroute).where(models.Traceroute.import_time < cutoff)
models.Traceroute.import_time_us < cutoff_us
)
) )
cleanup_logger.info(f"Deleted {result.rowcount} rows from Traceroute") cleanup_logger.info(f"Deleted {result.rowcount} rows from Traceroute")
@@ -195,19 +192,17 @@ async def daily_cleanup_at(
# Node # Node
# ------------------------- # -------------------------
result = await session.execute( result = await session.execute(
delete(models.Node).where(models.Node.last_seen_us < cutoff_us) delete(models.Node).where(models.Node.last_update < cutoff)
) )
cleanup_logger.info(f"Deleted {result.rowcount} rows from Node") cleanup_logger.info(f"Deleted {result.rowcount} rows from Node")
await session.commit() await session.commit()
if vacuum_db and mqtt_database.engine.dialect.name == "sqlite": if vacuum_db:
cleanup_logger.info("Running VACUUM...") cleanup_logger.info("Running VACUUM...")
async with mqtt_database.engine.begin() as conn: async with mqtt_database.engine.begin() as conn:
await conn.exec_driver_sql("VACUUM;") await conn.exec_driver_sql("VACUUM;")
cleanup_logger.info("VACUUM completed.") 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("Cleanup completed successfully.")
cleanup_logger.info("Ingestion resumed after cleanup.") cleanup_logger.info("Ingestion resumed after cleanup.")