49 Commits

Author SHA1 Message Date
Pablo Revilla
7f94bc0e39 Merge branch 'master' into revert-73-maphours-stacked 2025-10-15 21:36:06 -07:00
Pablo Revilla
5d687da598 Merge pull request #76 from pablorevilla-meshtastic/revert-75-10-15-25-bugs
Revert "fixed map to show only channels with locations"
2025-10-15 21:33:16 -07:00
Pablo Revilla
a002cde2d7 Revert "fixed map to show only channels with locations" 2025-10-15 21:32:50 -07:00
Pablo Revilla
454c8ff6e2 Start adding language support 2025-10-15 16:27:43 -07:00
Pablo Revilla
021bc54f9d Start adding language support 2025-10-15 16:25:34 -07:00
Pablo Revilla
155ef89724 Merge remote-tracking branch 'origin/master' 2025-10-15 16:24:07 -07:00
Pablo Revilla
084647eec1 Start adding language support 2025-10-15 16:23:59 -07:00
Pablo Revilla
c13a851145 Merge pull request #75 from nullrouten0/10-15-25-bugs
fixed map to show only channels with locations
2025-10-15 16:15:01 -07:00
Pablo Revilla
114cd980b9 Merge branch 'master' into 10-15-25-bugs 2025-10-15 16:14:47 -07:00
Nathan
c23a650c0d fixed map to show only channels with locations 2025-10-15 16:07:52 -07:00
Pablo Revilla
318bf83403 Revert "Maphours changes stacked with filtering additions" 2025-10-15 15:57:56 -07:00
Pablo Revilla
636ab3e976 Start adding language support 2025-10-15 15:57:39 -07:00
Pablo Revilla
ea10a656e7 Start adding language support 2025-10-15 15:31:04 -07:00
Pablo Revilla
bcd007e5e2 Merge pull request #73 from nullrouten0/maphours-stacked
Maphours changes stacked with filtering additions
2025-10-15 15:09:24 -07:00
Nathan
b35acde821 Add channel-aware activity filters and API-driven dashboards 2025-10-14 21:34:52 -07:00
Nathan
b7752bc315 Map: activity time filters 2025-10-11 21:21:10 -07:00
Nathan
257bf7ffac Add channel filters to stats, chat, and firehose views 2025-10-11 16:28:34 -07:00
Pablo Revilla
d561d1a8de Start adding language support 2025-10-10 21:37:45 -07:00
Pablo Revilla
60e7389d83 Start adding language support 2025-10-10 21:34:42 -07:00
Pablo Revilla
4ac3262544 Start adding language support 2025-10-10 21:34:36 -07:00
Pablo Revilla
87643e4bd2 Start adding language support 2025-10-10 21:32:36 -07:00
Pablo Revilla
29174a649c Start adding language support 2025-10-10 21:28:48 -07:00
Pablo Revilla
712aea5139 Start adding language support 2025-10-10 21:10:35 -07:00
Pablo Revilla
d6fadd99d0 Start adding language support 2025-10-10 21:01:43 -07:00
Pablo Revilla
ae0b0944f0 Merge pull request #68 from jkrauska/profileTop
Add database indexes for 10X improvement in page load for /top
2025-10-10 12:58:41 -07:00
Pablo Revilla
d7b830e2f7 Merge pull request #69 from jkrauska/lornet.pl
fix for loranet.pl missing gateway_id
2025-10-08 18:31:05 -07:00
Joel Krauska
4a1737ebd4 fix for loranet.pl 2025-10-07 20:13:00 -07:00
Joel Krauska
60131007df fix for ruff 2025-10-07 19:49:57 -07:00
Joel Krauska
23d66c0d67 add database indexes 2025-10-07 18:22:50 -07:00
Pablo Revilla
30ba603f66 Merge pull request #67 from jkrauska/nodeListFavorites
FEATURE: Add NodeList Favorites and Remember Map Filters
2025-10-07 16:08:52 -07:00
Pablo Revilla
9811102681 Merge pull request #66 from jkrauska/apiEdges
BUG: Fix for api/edges traceback
2025-10-07 15:54:04 -07:00
Joel Krauska
7c92b06bec use ruff format 2025-10-07 14:15:29 -07:00
Joel Krauska
adda666a39 Add Favorites and Remember Filters 2025-10-07 14:04:14 -07:00
Joel Krauska
3e673f30bc Fix for api/edges traceback 2025-10-07 13:59:20 -07:00
Pablo Revilla
beefb4c5df Merge pull request #64 from jkrauska/ruffVersionFix
Bump ruff version - fix open call from lang work
2025-10-03 21:56:19 -07:00
Joel Krauska
e1bada8378 Bump ruff version - fix open call from lang work 2025-10-03 21:34:13 -07:00
Pablo Revilla
fbd6fcb123 Merge pull request #62 from jkrauska/ruffAutomation
Automate ruff in github action
2025-10-03 21:03:53 -07:00
Pablo Revilla
5d267effa5 Remove unused code 2025-10-03 20:56:47 -07:00
Joel Krauska
e28d248cf9 Automate ruff in github action 2025-10-03 20:55:11 -07:00
Pablo Revilla
ab101dd461 Merge pull request #61 from jkrauska/jkrauska/ruffFormat
Add Ruff formatting and pre-commit hooks
2025-10-03 20:49:44 -07:00
Joel Krauska
35212d403e Merge branch 'master' into jkrauska/ruffFormat 2025-10-03 20:43:16 -07:00
Pablo Revilla
3603014fd2 Added maps coordinates to /api/config 2025-10-03 20:41:02 -07:00
Joel Krauska
e25ff22127 Add Ruff formatting and pre-commit hooks
Configure Ruff as the code formatter and linter with pre-commit hooks.
  Applied automatic formatting fixes across the entire codebase including:
  - Import sorting and organization
  - Code style consistency (spacing, line breaks, indentation)
  - String quote normalization
  - Removal of trailing whitespace and unnecessary blank lines
2025-10-03 20:38:37 -07:00
Pablo Revilla
aa9922e7fa work on error where packet ids could be duplicate and crash the loop 2025-10-03 12:54:00 -07:00
Pablo Revilla
a9b16d6c18 work on error where packet ids could be duplicate and crash the loop 2025-10-03 12:33:04 -07:00
Pablo Revilla
b4fda0bb01 Merge remote-tracking branch 'origin/master' 2025-10-03 11:59:35 -07:00
Pablo Revilla
215817abc7 Cleanup the install process 2025-10-03 11:59:21 -07:00
Pablo Revilla
f167e8780d Merge pull request #57 from jkrauska/jkrauska/startupLogging
Add structured logging and improved startup/shutdown handling
2025-10-03 08:58:31 -07:00
Joel Krauska
2723022dd5 Add structured logging and improved startup/shutdown handling
- Add consistent logging format across all modules (timestamp, file:line, PID, level)
- Add startup logging for MQTT connection, web server startup with URL display
- Add MQTT message processing metrics (count and rate logging every 10k messages)
- Add graceful shutdown handling with signal handlers and PID file cleanup
- Add configurable HTTP access log toggle via config.ini (default: disabled)
- Replace print() statements with proper logger calls throughout
- Update .gitignore to exclude PID files (meshview-db.pid, meshview-web.pid)
- Update documentation for new logging configuration options

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-01 17:49:01 -07:00
34 changed files with 2689 additions and 1158 deletions

39
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Ruff
on:
pull_request:
paths:
- "**/*.py"
- "pyproject.toml"
- "ruff.toml"
- ".pre-commit-config.yaml"
jobs:
ruff:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Cache Ruff
uses: actions/cache@v4
with:
path: ~/.cache/ruff
key: ruff-${{ runner.os }}-${{ hashFiles('**/pyproject.toml', '**/ruff.toml') }}
- name: Install Ruff
run: pip install "ruff==0.13.3"
# Lint (with GitHub annotation format for inline PR messages)
- name: Ruff check
run: ruff check --output-format=github .
# Fail PR if formatting is needed
- name: Ruff format (check-only)
run: ruff format --check .
# TODO: Investigate only applying to changed files and possibly apply fixes

2
.gitignore vendored
View File

@@ -3,6 +3,8 @@ __pycache__/*
meshview/__pycache__/*
meshtastic/protobuf/*
packets.db
meshview-db.pid
meshview-web.pid
/table_details.py
config.ini
screenshots/*

8
.pre-commit-config.yaml Normal file
View File

@@ -0,0 +1,8 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.3 # pin the latest youre comfortable with
hooks:
- id: ruff
args: [--fix, --exit-non-zero-on-fix] # fail if it had to change files
- id: ruff-format

203
PERFORMANCE_OPTIMIZATION.md Normal file
View File

@@ -0,0 +1,203 @@
# /top Endpoint Performance Optimization
## Problem
The `/top` endpoint was taking over 1 second to execute due to inefficient database queries. The query joins three tables (node, packet, packet_seen) and performs COUNT aggregations on large result sets without proper indexes.
## Root Cause Analysis
The `get_top_traffic_nodes()` query in `meshview/store.py` executes:
```sql
SELECT
n.node_id,
n.long_name,
n.short_name,
n.channel,
COUNT(DISTINCT p.id) AS total_packets_sent,
COUNT(ps.packet_id) AS total_times_seen
FROM node n
LEFT JOIN packet p ON n.node_id = p.from_node_id
AND p.import_time >= DATETIME('now', 'localtime', '-24 hours')
LEFT JOIN packet_seen ps ON p.id = ps.packet_id
GROUP BY n.node_id, n.long_name, n.short_name
HAVING total_packets_sent > 0
ORDER BY total_times_seen DESC;
```
### Performance Bottlenecks Identified:
1. **Missing composite index on packet(from_node_id, import_time)**
- The query filters packets by BOTH `from_node_id` AND `import_time >= -24 hours`
- Without a composite index, SQLite must:
- Scan using `idx_packet_from_node_id` index
- Then filter each result by `import_time` (expensive!)
2. **Missing index on packet_seen(packet_id)**
- The LEFT JOIN to packet_seen uses `packet_id` as the join key
- Without an index, SQLite performs a table scan for each packet
- With potentially millions of packet_seen records, this is very slow
## Solution
### 1. Added Database Indexes
Modified `meshview/models.py` to include two new indexes:
```python
# In Packet class
Index("idx_packet_from_node_time", "from_node_id", desc("import_time"))
# In PacketSeen class
Index("idx_packet_seen_packet_id", "packet_id")
```
### 2. Added Performance Profiling
Modified `meshview/web.py` `/top` endpoint to include:
- Timing instrumentation for database queries
- Timing for data processing
- Detailed logging with `[PROFILE /top]` prefix
- On-page performance metrics display
### 3. Created Migration Script
Created `add_db_indexes.py` to add indexes to existing databases.
## Implementation Steps
### Step 1: Stop the Database Writer
```bash
# Stop startdb.py if it's running
pkill -f startdb.py
```
### Step 2: Run Migration Script
```bash
python add_db_indexes.py
```
Expected output:
```
======================================================================
Database Index Migration for /top Endpoint Performance
======================================================================
Connecting to database: sqlite+aiosqlite:///path/to/packets.db
======================================================================
Checking for index: idx_packet_from_node_time
======================================================================
Creating index idx_packet_from_node_time...
Table: packet
Columns: from_node_id, import_time DESC
Purpose: Speeds up filtering packets by sender and time range
✓ Index created successfully in 2.34 seconds
======================================================================
Checking for index: idx_packet_seen_packet_id
======================================================================
Creating index idx_packet_seen_packet_id...
Table: packet_seen
Columns: packet_id
Purpose: Speeds up joining packet_seen with packet table
✓ Index created successfully in 3.12 seconds
... (index listings)
======================================================================
Migration completed successfully!
======================================================================
```
### Step 3: Restart Services
```bash
# Restart server
python mvrun.py &
```
### Step 4: Verify Performance Improvement
1. Visit `/top` endpoint eg http://127.0.0.1:8081/top?perf=true
2. Scroll to bottom of page
3. Check the Performance Metrics panel
4. Compare DB query time before and after
**Expected Results:**
- **Before:** 1000-2000ms query time
- **After:** 50-200ms query time
- **Improvement:** 80-95% reduction
## Performance Metrics
The `/top` page now displays at the bottom:
```
⚡ Performance Metrics
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Database Query: 45.23ms
Data Processing: 2.15ms
Total Time: 47.89ms
Nodes Processed: 156
Total Packets: 45,678
Times Seen: 123,456
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
```
## Technical Details
### Why Composite Index Works
SQLite can use a composite index `(from_node_id, import_time DESC)` to:
1. Quickly find all packets for a specific `from_node_id`
2. Filter by `import_time` without additional I/O (data is already sorted)
3. Both operations use a single index lookup
### Why packet_id Index Works
The `packet_seen` table can have millions of rows. Without an index:
- Each packet requires a full table scan of packet_seen
- O(n * m) complexity where n=packets, m=packet_seen rows
With the index:
- Each packet uses an index lookup
- O(n * log m) complexity - dramatically faster
### Index Size Impact
- `idx_packet_from_node_time`: ~10-20% of packet table size
- `idx_packet_seen_packet_id`: ~5-10% of packet_seen table size
- Total additional disk space: typically 50-200MB depending on data volume
- Performance gain: 80-95% query time reduction
## Future Optimizations
If query is still slow after indexes:
1. **Add ANALYZE**: Run `ANALYZE;` to update SQLite query planner statistics
2. **Consider materialized view**: Pre-compute traffic stats in a background job
3. **Add caching**: Cache results for 5-10 minutes using Redis/memcached
4. **Partition data**: Archive old packet_seen records
## Rollback
If needed, indexes can be removed:
```sql
DROP INDEX IF EXISTS idx_packet_from_node_time;
DROP INDEX IF EXISTS idx_packet_seen_packet_id;
```
## Files Modified
- `meshview/models.py` - Added index definitions
- `meshview/web.py` - Added performance profiling
- `meshview/templates/top.html` - Added metrics display
- `add_db_indexes.py` - Migration script (NEW)
- `PERFORMANCE_OPTIMIZATION.md` - This documentation (NEW)
## Support
For questions or issues:
1. Verify indexes exist: `python add_db_indexes.py` (safe to re-run)
2. Review SQLite EXPLAIN QUERY PLAN for the query

View File

@@ -74,20 +74,19 @@ Create a Python virtual environment:
from the meshview directory...
```bash
uv venv env || python3 -m venv env
python3 -m venv env
```
Install the environment requirements:
```bash
uv pip install -r requirements.txt || ./env/bin/pip install -r requirements.txt
./env/bin/pip install -r requirements.txt
```
Install `graphviz` on MacOS or Debian/Ubuntu Linux:
```bash
[ "$(uname)" = "Darwin" ] && brew install graphviz
[ "$(uname)" = "Linux" ] && sudo apt-get install graphviz
sudo apt-get install graphviz
```
Copy `sample.config.ini` to `config.ini`:
@@ -210,6 +209,17 @@ hour = 2
minute = 00
# Run VACUUM after cleanup
vacuum = False
# -------------------------
# Logging Configuration
# -------------------------
[logging]
# Enable or disable HTTP access logs from the web server
# When disabled, request logs like "GET /api/chat" will not appear
# Application logs (errors, startup messages, etc.) are unaffected
# Set to True to enable, False to disable (default: False)
access_log = False
```
---
@@ -402,5 +412,3 @@ Add schedule to the bottom of the file (modify /path/to/file/ to the correct pat
```
Check the log file to see it the script run at the specific time.

154
add_db_indexes.py Normal file
View File

@@ -0,0 +1,154 @@
#!/usr/bin/env python3
"""
Migration script to add performance indexes
This script adds two critical indexes:
1. idx_packet_from_node_time: Composite index on packet(from_node_id, import_time DESC)
2. idx_packet_seen_packet_id: Index on packet_seen(packet_id)
These indexes significantly improve the performance of the get_top_traffic_nodes() query.
Usage:
python add_db_indexes.py
The script will:
- Connect to your database in WRITE mode
- Check if indexes already exist
- Create missing indexes
- Report timing for each operation
"""
import asyncio
import time
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from meshview.config import CONFIG
async def add_indexes():
# Get database connection string and remove read-only flag
db_string = CONFIG["database"]["connection_string"]
if "?mode=ro" in db_string:
db_string = db_string.replace("?mode=ro", "")
print(f"Connecting to database: {db_string}")
# Create engine with write access
engine = create_async_engine(db_string, echo=False, connect_args={"uri": True})
try:
async with engine.begin() as conn:
# Check and create idx_packet_from_node_time
print("\n" + "=" * 70)
print("Checking for index: idx_packet_from_node_time")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name FROM sqlite_master
WHERE type='index' AND name='idx_packet_from_node_time'
""")
)
if result.fetchone():
print("✓ Index idx_packet_from_node_time already exists")
else:
print("Creating index idx_packet_from_node_time...")
print(" Table: packet")
print(" Columns: from_node_id, import_time DESC")
print(" Purpose: Speeds up filtering packets by sender and time range")
start_time = time.perf_counter()
await conn.execute(
text("""
CREATE INDEX idx_packet_from_node_time
ON packet(from_node_id, import_time DESC)
""")
)
elapsed = time.perf_counter() - start_time
print(f"✓ Index created successfully in {elapsed:.2f} seconds")
# Check and create idx_packet_seen_packet_id
print("\n" + "=" * 70)
print("Checking for index: idx_packet_seen_packet_id")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name FROM sqlite_master
WHERE type='index' AND name='idx_packet_seen_packet_id'
""")
)
if result.fetchone():
print("✓ Index idx_packet_seen_packet_id already exists")
else:
print("Creating index idx_packet_seen_packet_id...")
print(" Table: packet_seen")
print(" Columns: packet_id")
print(" Purpose: Speeds up joining packet_seen with packet table")
start_time = time.perf_counter()
await conn.execute(
text("""
CREATE INDEX idx_packet_seen_packet_id
ON packet_seen(packet_id)
""")
)
elapsed = time.perf_counter() - start_time
print(f"✓ Index created successfully in {elapsed:.2f} seconds")
# Show index info
print("\n" + "=" * 70)
print("Current indexes on packet table:")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name, sql FROM sqlite_master
WHERE type='index' AND tbl_name='packet'
ORDER BY name
""")
)
for row in result:
if row[1]: # Skip auto-indexes (they have NULL sql)
print(f"{row[0]}")
print("\n" + "=" * 70)
print("Current indexes on packet_seen table:")
print("=" * 70)
result = await conn.execute(
text("""
SELECT name, sql FROM sqlite_master
WHERE type='index' AND tbl_name='packet_seen'
ORDER BY name
""")
)
for row in result:
if row[1]: # Skip auto-indexes
print(f"{row[0]}")
print("\n" + "=" * 70)
print("Migration completed successfully!")
print("=" * 70)
print("\nNext steps:")
print("1. Restart your web server (mvrun.py)")
print("2. Visit /top endpoint and check the performance metrics")
print("3. Compare DB query time with previous measurements")
print("\nExpected improvement: 50-90% reduction in query time")
except Exception as e:
print(f"\n❌ Error during migration: {e}")
raise
finally:
await engine.dispose()
if __name__ == "__main__":
print("=" * 70)
print("Database Index Migration for Endpoint Performance")
print("=" * 70)
asyncio.run(add_indexes())

133
contributing.md Normal file
View File

@@ -0,0 +1,133 @@
# Contributing to Meshview
First off, thanks for taking the time to contribute! ❤️
All types of contributions are encouraged and valued. See the [Table of Contents](#table-of-contents) for ways to help and details about how this project handles contributions. Please read the relevant section before getting started — it will make things smoother for both you and the maintainers.
The Meshview community looks forward to your contributions. 🎉
> And if you like the project but dont have time to contribute code, thats fine! You can still support Meshview by:
> - ⭐ Starring the repo on GitHub
> - Talking about Meshview on social media
> - Referencing Meshview in your own projects README
> - Mentioning Meshview at local meetups or to colleagues/friends
---
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [I Have a Question](#i-have-a-question)
- [I Want to Contribute](#i-want-to-contribute)
- [Reporting Bugs](#reporting-bugs)
- [Suggesting Enhancements](#suggesting-enhancements)
- [Your First Code Contribution](#your-first-code-contribution)
- [Improving the Documentation](#improving-the-documentation)
- [Styleguides](#styleguides)
- [Commit Messages](#commit-messages)
- [Join the Project Team](#join-the-project-team)
---
## Code of Conduct
Meshview is an open and welcoming community. We want everyone to feel safe, respected, and valued.
### Our Standards
- Be respectful and considerate in all interactions.
- Welcome new contributors and help them learn.
- Provide constructive feedback, not personal attacks.
- Focus on collaboration and what benefits the community.
Unacceptable behavior includes harassment, insults, hate speech, personal attacks, or publishing others private information without permission.
---
## I Have a Question
> Before asking, please read the [documentation](docs/README.md) if available.
1. Search the [issues list](../../issues) to see if your question has already been asked.
2. If not, open a [new issue](../../issues/new) with the **question** label.
3. Provide as much context as possible (OS, Python version, database type, etc.).
---
## I Want to Contribute
### Legal Notice
By contributing to Meshview, you agree that:
- You authored the content yourself.
- You have the necessary rights to the content.
- Your contribution can be provided under the projects license.
---
### Reporting Bugs
Before submitting a bug report:
- Make sure youre using the latest Meshview version.
- Verify the issue is not due to a misconfigured environment (SQLite/MySQL, Python version, etc.).
- Search existing [bug reports](../../issues?q=label%3Abug).
- Collect relevant information:
- Steps to reproduce
- Error messages / stack traces
- OS, Python version, and database backend
- Any logs (`meshview-db.service`, `mqtt_reader.py`, etc.)
How to report:
- Open a [new issue](../../issues/new).
- Use a **clear and descriptive title**.
- Include reproduction steps and expected vs. actual behavior.
⚠️ Security issues should **not** be reported in public issues. Instead, email us at **meshview-maintainers@proton.me**.
---
### Suggesting Enhancements
Enhancements are tracked as [issues](../../issues). Before suggesting:
- Make sure the feature doesnt already exist.
- Search for prior suggestions.
- Check that it fits Meshviews scope (mesh packet analysis, visualization, telemetry, etc.).
When submitting:
- Use a **clear and descriptive title**.
- Describe the current behavior and what youd like to see instead.
- Include examples, screenshots, or mockups if relevant.
- Explain why it would be useful to most Meshview users.
---
### Your First Code Contribution
We love first-time contributors! 🚀
If youd like to start coding:
1. Look for issues tagged with [good first issue](../../issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
2. Fork the repository and clone it locally.
3. Set up the development environment:
4. Run the app locally
5. Create a new branch, make your changes, commit, and push.
6. Open a pull request!
---
### Improving the Documentation
Docs are just as important as code. You can help by:
- Fixing typos or broken links.
- Clarifying confusing instructions.
- Adding examples (e.g., setting up Nginx as a reverse proxy, SQLite vs. MySQL setup).
- Writing or updating tutorials.
---
## Join the Project Team
Meshview is a community-driven project. If you consistently contribute (code, documentation, or community help), wed love to invite you as a maintainer.
Start by contributing regularly, engaging in issues/PRs, and helping others.
---
✨ Thats it! Thanks again for being part of Meshview. Every contribution matters.

View File

@@ -1,12 +1,12 @@
import asyncio
from meshview import web
async def main():
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(
web.run_server()
)
tg.create_task(web.run_server())
if __name__ == '__main__':
asyncio.run(main())

View File

@@ -1,9 +1,11 @@
import configparser
import argparse
import configparser
# Parse command-line arguments
parser = argparse.ArgumentParser(description="MeshView Configuration Loader")
parser.add_argument("--config", type=str, default="config.ini", help="Path to config.ini file (default: config.ini)")
parser.add_argument(
"--config", type=str, default="config.ini", help="Path to config.ini file (default: config.ini)"
)
args = parser.parse_args()
# Initialize config parser
@@ -12,4 +14,3 @@ if not config_parser.read(args.config):
raise FileNotFoundError(f"Config file '{args.config}' not found! Ensure the file exists.")
CONFIG = {section: dict(config_parser.items(section)) for section in config_parser.sections()}

View File

@@ -1,6 +1,6 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from meshview import models
from sqlalchemy.ext.asyncio import async_sessionmaker
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
engine = None
async_session = None
@@ -13,10 +13,12 @@ def init_database(database_connection_string):
database_connection_string += "?mode=ro"
kwargs["connect_args"] = {"uri": True}
engine = create_async_engine(database_connection_string, **kwargs)
async_session = async_sessionmaker( bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
async_session = async_sessionmaker(
bind=engine,
class_=AsyncSession,
expire_on_commit=False,
)
async def create_tables():
async with engine.begin() as conn:

View File

@@ -1,16 +1,16 @@
from meshtastic.protobuf.mqtt_pb2 import MapReport
from meshtastic.protobuf.portnums_pb2 import PortNum
from google.protobuf.message import DecodeError
from meshtastic.protobuf.mesh_pb2 import (
Position,
MeshPacket,
NeighborInfo,
NodeInfo,
User,
Position,
RouteDiscovery,
Routing,
MeshPacket,
User,
)
from meshtastic.protobuf.mqtt_pb2 import MapReport
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshtastic.protobuf.telemetry_pb2 import Telemetry
from google.protobuf.message import DecodeError
def text_message(payload):
@@ -25,7 +25,7 @@ DECODE_MAP = {
PortNum.TRACEROUTE_APP: RouteDiscovery.FromString,
PortNum.ROUTING_APP: Routing.FromString,
PortNum.TEXT_MESSAGE_APP: text_message,
PortNum.MAP_REPORT_APP: MapReport.FromString
PortNum.MAP_REPORT_APP: MapReport.FromString,
}

110
meshview/lang/en.json Normal file
View File

@@ -0,0 +1,110 @@
{
"base": {
"conversations": "Conversations",
"nodes": "Nodes",
"everything": "See Everything",
"graph": "Mesh Graphs",
"net": "Weekly Net",
"map": "Live Map",
"stats": "Stats",
"top": "Top Traffic Nodes",
"footer": "Visit <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> on Github.",
"node id": "Node id",
"go to node": "Go to Node",
"all": "All",
"portnum_options": {
"1": "Text Message",
"3": "Position",
"4": "Node Info",
"67": "Telemetry",
"70": "Traceroute",
"71": "Neighbor Info"
},
"chat": {
"replying_to": "Replying to:",
"view_packet_details": "View packet details"
}
},
"nodelist": {
"search_placeholder": "Search by name or ID...",
"all_roles": "All Roles",
"all_channels": "All Channels",
"all_hw_models": "All HW Models",
"all_firmware": "All Firmware",
"export_csv": "Export CSV",
"clear_filters": "Clear Filters",
"showing": "Showing",
"nodes": "nodes",
"short": "Short",
"long_name": "Long Name",
"hw_model": "HW Model",
"firmware": "Firmware",
"role": "Role",
"last_lat": "Last Latitude",
"last_long": "Last Longitude",
"channel": "Channel",
"last_update": "Last Update",
"loading_nodes": "Loading nodes...",
"no_nodes": "No nodes found",
"error_nodes": "Error loading nodes"
},
"net": {
"number_of_checkins": "Number of Check-ins:",
"view_packet_details": "View packet details",
"view_all_packets_from_node": "View all packets from this node",
"no_packets_found": "No packets found."
},
"map": {
"channel": "Channel:",
"model": "Model:",
"role": "Role:",
"last_seen": "Last seen:",
"firmware": "Firmware:",
"show_routers_only": "Show Routers Only",
"share_view": "Share This View"
},
"stats":
{
"mesh_stats_summary": "Mesh Statistics - Summary (all available in Database)",
"total_nodes": "Total Nodes",
"total_packets": "Total Packets",
"total_packets_seen": "Total Packets Seen",
"packets_per_day_all": "Packets per Day - All Ports (Last 14 Days)",
"packets_per_day_text": "Packets per Day - Text Messages (Port 1, Last 14 Days)",
"packets_per_hour_all": "Packets per Hour - All Ports",
"packets_per_hour_text": "Packets per Hour - Text Messages (Port 1)",
"packet_types_last_24h": "Packet Types - Last 24 Hours",
"hardware_breakdown": "Hardware Breakdown",
"role_breakdown": "Role Breakdown",
"channel_breakdown": "Channel Breakdown",
"expand_chart": "Expand Chart",
"export_csv": "Export CSV",
"all_channels": "All Channels"
},
"top":
{
"top_traffic_nodes": "Top Traffic Nodes (last 24 hours)",
"chart_description_1": "This chart shows a bell curve (normal distribution) based on the total \"Times Seen\" values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.",
"chart_description_2": "This \"Times Seen\" value is the closest that we can get to Mesh utilization by node.",
"mean_label": "Mean:",
"stddev_label": "Standard Deviation:",
"long_name": "Long Name",
"short_name": "Short Name",
"channel": "Channel",
"packets_sent": "Packets Sent",
"times_seen": "Times Seen",
"seen_percent": "Seen % of Mean",
"no_nodes": "No top traffic nodes available."
},
"nodegraph":
{
"channel_label": "Channel:",
"search_node_placeholder": "Search node...",
"search_button": "Search",
"long_name_label": "Long Name:",
"short_name_label": "Short Name:",
"role_label": "Role:",
"hw_model_label": "Hardware Model:",
"node_not_found": "Node not found in current channel!"
}
}

112
meshview/lang/es.json Normal file
View File

@@ -0,0 +1,112 @@
{
"base": {
"conversations": "Conversaciones",
"nodes": "Nodos",
"everything": "Mostrar Todo",
"graph": "Gráficos de la Malla",
"net": "Red Semanal",
"map": "Mapa en Vivo",
"stats": "Estadísticas",
"top": "Nodos con Mayor Tráfico",
"footer": "Visita <strong><a href=\"https://github.com/pablorevilla-meshtastic/meshview\">Meshview</a></strong> en Github.",
"node id": "ID de Nodo",
"go to node": "Ir al nodo",
"all": "Todos",
"portnum_options": {
"1": "Mensaje de Texto",
"3": "Ubicación",
"4": "Información del Nodo",
"67": "Telemetría",
"70": "Traceroute",
"71": "Información de Vecinos"
}
},
"chat": {
"replying_to": "Respondiendo a:",
"view_packet_details": "Ver detalles del paquete"
},
"nodelist": {
"search_placeholder": "Buscar por nombre o ID...",
"all_roles": "Todos los Roles",
"all_channels": "Todos los Canales",
"all_hw_models": "Todos los Modelos",
"all_firmware": "Todo el Firmware",
"export_csv": "Exportar CSV",
"clear_filters": "Limpiar Filtros",
"showing": "Mostrando",
"nodes": "nodos",
"short": "Corto",
"long_name": "Largo",
"hw_model": "Modelo",
"firmware": "Firmware",
"role": "Rol",
"last_lat": "Última Latitud",
"last_long": "Última Longitud",
"channel": "Canal",
"last_update": "Última Actualización",
"loading_nodes": "Cargando nodos...",
"no_nodes": "No se encontraron nodos",
"error_nodes": "Error al cargar nodos"
},
"net": {
"number_of_checkins": "Número de registros:",
"view_packet_details": "Ver detalles del paquete",
"view_all_packets_from_node": "Ver todos los paquetes de este nodo",
"no_packets_found": "No se encontraron paquetes."
},
"map": {
"channel": "Canal:",
"model": "Modelo:",
"role": "Rol:",
"last_seen": "Visto por última vez:",
"firmware": "Firmware:",
"show_routers_only": "Mostrar solo enrutadores",
"share_view": "Compartir esta vista"
},
"stats": {
"mesh_stats_summary": "Estadísticas de la Malla - Resumen (completas en la base de datos)",
"total_nodes": "Nodos Totales",
"total_packets": "Paquetes Totales",
"total_packets_seen": "Paquetes Totales Vistos",
"packets_per_day_all": "Paquetes por Día - Todos los Puertos (Últimos 14 Días)",
"packets_per_day_text": "Paquetes por Día - Mensajes de Texto (Puerto 1, Últimos 14 Días)",
"packets_per_hour_all": "Paquetes por Hora - Todos los Puertos",
"packets_per_hour_text": "Paquetes por Hora - Mensajes de Texto (Puerto 1)",
"packet_types_last_24h": "Tipos de Paquetes - Últimas 24 Horas",
"hardware_breakdown": "Distribución de Hardware",
"role_breakdown": "Distribución de Roles",
"channel_breakdown": "Distribución de Canales",
"expand_chart": "Ampliar Gráfico",
"export_csv": "Exportar CSV",
"all_channels": "Todos los Canales"
},
"top": {
"top_traffic_nodes": "Tráfico (últimas 24 horas)",
"chart_description_1": "Este gráfico muestra una curva normal (distribución normal) basada en el valor total de \"Veces Visto\" para todos los nodos. Ayuda a visualizar con qué frecuencia se detectan los nodos en relación con el promedio.",
"chart_description_2": "Este valor de \"Veces Visto\" es lo más aproximado que tenemos al nivel de uso de la malla por nodo.",
"mean_label": "Media:",
"stddev_label": "Desviación Estándar:",
"long_name": "Nombre Largo",
"short_name": "Nombre Corto",
"channel": "Canal",
"packets_sent": "Paquetes Enviados",
"times_seen": "Veces Visto",
"seen_percent": "% Visto respecto a la Media",
"no_nodes": "No hay nodos con mayor tráfico disponibles."
},
"nodegraph":
{
"channel_label": "Canal:",
"search_placeholder": "Buscar nodo...",
"search_button": "Buscar",
"long_name_label": "Nombre completo:",
"short_name_label": "Nombre corto:",
"role_label": "Rol:",
"hw_model_label": "Modelo de hardware:",
"traceroute": "Traceroute",
"neighbor": "Vecino",
"other": "Otro",
"unknown": "Desconocido",
"node_not_found": "¡Nodo no encontrado en el canal actual!"
}
}

View File

@@ -1,8 +1,8 @@
from datetime import datetime
from sqlalchemy.orm import DeclarativeBase, foreign
from sqlalchemy import BigInteger, ForeignKey, Index, desc
from sqlalchemy.ext.asyncio import AsyncAttrs
from sqlalchemy.orm import mapped_column, relationship, Mapped
from sqlalchemy import ForeignKey, BigInteger, Index, desc
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(AsyncAttrs, DeclarativeBase):
@@ -24,9 +24,7 @@ class Node(Base):
channel: Mapped[str] = mapped_column(nullable=True)
last_update: Mapped[datetime] = mapped_column(nullable=True)
__table_args__ = (
Index("idx_node_node_id", "node_id"),
)
__table_args__ = (Index("idx_node_node_id", "node_id"),)
def to_dict(self):
return {
@@ -58,6 +56,8 @@ class Packet(Base):
Index("idx_packet_from_node_id", "from_node_id"),
Index("idx_packet_to_node_id", "to_node_id"),
Index("idx_packet_import_time", desc("import_time")),
# 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")),
)
@@ -81,10 +81,11 @@ class PacketSeen(Base):
__table_args__ = (
Index("idx_packet_seen_node_id", "node_id"),
# Index for /top endpoint performance - JOIN on packet_id
Index("idx_packet_seen_packet_id", "packet_id"),
)
class Traceroute(Base):
__tablename__ = "traceroute"
@@ -98,6 +99,4 @@ class Traceroute(Base):
route: Mapped[bytes] = mapped_column(nullable=True)
import_time: Mapped[datetime] = mapped_column(nullable=True)
__table_args__ = (
Index("idx_traceroute_import_time", "import_time"),
)
__table_args__ = (Index("idx_traceroute_import_time", "import_time"),)

View File

@@ -1,11 +1,16 @@
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from meshview import models
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
def init_database(database_connection_string):
global engine, async_session
engine = create_async_engine(database_connection_string, echo=False, connect_args={"timeout": 900})
engine = create_async_engine(
database_connection_string, echo=False, connect_args={"timeout": 900}
)
async_session = async_sessionmaker(engine, expire_on_commit=False)
async def create_tables():
async with engine.begin() as conn:
await conn.run_sync(models.Base.metadata.create_all)

View File

@@ -1,13 +1,25 @@
import base64
import asyncio
import base64
import logging
import random
import time
import aiomqtt
from google.protobuf.message import DecodeError
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from google.protobuf.message import DecodeError
from meshtastic.protobuf.mqtt_pb2 import ServiceEnvelope
KEY = base64.b64decode("1PG7OiApB1nwvP+rz05pAQ==")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(filename)s:%(lineno)d [pid:%(process)d] %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
def decrypt(packet):
if packet.HasField("decoded"):
@@ -27,6 +39,8 @@ def decrypt(packet):
async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd):
identifier = str(random.getrandbits(16))
msg_count = 0
start_time = None
while True:
try:
async with aiomqtt.Client(
@@ -36,10 +50,15 @@ async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_pa
password=mqtt_passwd,
identifier=identifier,
) as client:
logger.info(f"Connected to MQTT broker at {mqtt_server}:{mqtt_port}")
for topic in topics:
print(f"Subscribing to: {topic}")
logger.info(f"Subscribing to: {topic}")
await client.subscribe(topic)
# Reset start time when connected
if start_time is None:
start_time = time.time()
async for msg in client.messages:
try:
envelope = ServiceEnvelope.FromString(msg.payload)
@@ -52,11 +71,23 @@ async def get_topic_envelopes(mqtt_server, mqtt_port, topics, mqtt_user, mqtt_pa
continue
# Skip packets from specific node
# FIXME: make this configurable as a list of node IDs to skip
if getattr(envelope.packet, "from", None) == 2144342101:
continue
msg_count += 1
# FIXME: make this interval configurable or time based
if (
msg_count % 10000 == 0
): # Log notice every 10000 messages (approx every hour at 3/sec)
elapsed_time = time.time() - start_time
msg_rate = msg_count / elapsed_time if elapsed_time > 0 else 0
logger.info(
f"Processed {msg_count} messages so far... ({msg_rate:.2f} msg/sec)"
)
yield msg.topic.value, envelope
except aiomqtt.MqttError as e:
print(f"MQTT error: {e}, reconnecting in 1s...")
logger.error(f"MQTT error: {e}, reconnecting in 1s...")
await asyncio.sleep(1)

View File

@@ -1,18 +1,18 @@
import datetime
import re
from sqlalchemy import select
from sqlalchemy import update
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from meshtastic.protobuf.config_pb2 import Config
from meshtastic.protobuf.mesh_pb2 import HardwareModel
from meshtastic.protobuf.portnums_pb2 import PortNum
from meshtastic.protobuf.mesh_pb2 import User, HardwareModel
from meshview import mqtt_database
from meshview import decode_payload
from meshview.models import Packet, PacketSeen, Node, Traceroute
from meshview import decode_payload, mqtt_database
from meshview.models import Node, Packet, PacketSeen, Traceroute
async def process_envelope(topic, env):
# Checking if the received packet is a MAP_REPORT
# MAP_REPORT_APP
if env.packet.decoded.portnum == PortNum.MAP_REPORT_APP:
node_id = getattr(env.packet, "from")
user_id = f"!{node_id:0{8}x}"
@@ -72,28 +72,42 @@ async def process_envelope(topic, env):
return
async with mqtt_database.async_session() as session:
result = await session.execute(
select(Packet).where(Packet.id == env.packet.id)
)
new_packet = False
# --- Packet insert with ON CONFLICT DO NOTHING
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
# FIXME: Not Used
# new_packet = False
packet = result.scalar_one_or_none()
if not packet:
new_packet = True
packet = Packet(
id=env.packet.id,
portnum=env.packet.decoded.portnum,
from_node_id=getattr(env.packet, "from"),
to_node_id=env.packet.to,
payload=env.packet.SerializeToString(),
import_time=datetime.datetime.now(),
channel=env.channel_id,
# FIXME: Not Used
# new_packet = True
stmt = (
sqlite_insert(Packet)
.values(
id=env.packet.id,
portnum=env.packet.decoded.portnum,
from_node_id=getattr(env.packet, "from"),
to_node_id=env.packet.to,
payload=env.packet.SerializeToString(),
import_time=datetime.datetime.now(),
channel=env.channel_id,
)
.on_conflict_do_nothing(index_elements=["id"])
)
session.add(packet)
await session.execute(stmt)
# --- PacketSeen (no conflict handling here, normal insert)
if not env.gateway_id:
print("WARNING: Missing gateway_id, skipping PacketSeen entry")
# Most likely a misconfiguration of a mqtt publisher?
return
else:
node_id = int(env.gateway_id[1:], 16)
result = await session.execute(
select(PacketSeen).where(
PacketSeen.packet_id == env.packet.id,
PacketSeen.node_id == int(env.gateway_id[1:], 16),
PacketSeen.node_id == node_id,
PacketSeen.rx_time == env.packet.rx_time,
)
)
@@ -112,13 +126,13 @@ async def process_envelope(topic, env):
)
session.add(seen)
# --- NODEINFO_APP handling
if env.packet.decoded.portnum == PortNum.NODEINFO_APP:
try:
user = decode_payload.decode_payload(
PortNum.NODEINFO_APP, env.packet.decoded.payload
)
if user and user.id:
# ✅ Safe fix: only parse hex IDs, otherwise leave None
if user.id[0] == "!" and re.fullmatch(r"[0-9a-fA-F]+", user.id[1:]):
node_id = int(user.id[1:], 16)
else:
@@ -136,9 +150,7 @@ async def process_envelope(topic, env):
)
node = (
await session.execute(
select(Node).where(Node.id == user.id)
)
await session.execute(select(Node).where(Node.id == user.id))
).scalar_one_or_none()
if node:
@@ -164,6 +176,7 @@ async def process_envelope(topic, env):
except Exception as e:
print(f"Error processing NODEINFO_APP: {e}")
# --- POSITION_APP handling
if env.packet.decoded.portnum == PortNum.POSITION_APP:
position = decode_payload.decode_payload(
PortNum.POSITION_APP, env.packet.decoded.payload
@@ -171,15 +184,14 @@ async def process_envelope(topic, env):
if position and position.latitude_i and position.longitude_i:
from_node_id = getattr(env.packet, "from")
node = (
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()
if node:
node.last_lat = position.latitude_i
node.last_long = position.longitude_i
session.add(node)
# --- TRACEROUTE_APP (no conflict handling, normal insert)
if env.packet.decoded.portnum == PortNum.TRACEROUTE_APP:
packet_id = None
if env.packet.decoded.want_response:
@@ -202,6 +214,7 @@ async def process_envelope(topic, env):
)
await session.commit()
if new_packet:
await packet.awaitable_attrs.to_node
await packet.awaitable_attrs.from_node
# if new_packet:
# await packet.awaitable_attrs.to_node
# await packet.awaitable_attrs.from_node

View File

@@ -1,6 +1,6 @@
import asyncio
import contextlib
from collections import defaultdict
import asyncio
waiting_node_ids_events = defaultdict(set)
@@ -36,11 +36,13 @@ def create_event(node_id):
def remove_event(node_event):
waiting_node_ids_events[node_event.node_id].remove(node_event)
def notify_packet(node_id, packet):
for event in waiting_node_ids_events[node_id]:
event.packets.append(packet)
event.set()
def notify_uplinked(node_id, packet):
for event in waiting_node_ids_events[node_id]:
event.uplinked.append(packet)

225
meshview/static/kiosk.html Normal file
View File

@@ -0,0 +1,225 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Mesh Nodes Live Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<style>
body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
#map { height: 100vh; width: 100%; }
#legend {
position: absolute;
bottom: 10px;
right: 10px;
background: white; /* changed from rgba(0,0,0,0.8) to white */
color: black; /* text color black */
padding: 10px;
border-radius: 5px;
z-index: 1000;
font-size: 13px;
line-height: 1.5;
border: 1px solid #ccc; /* optional: subtle border for white bg */
}
#filter-container { margin-bottom: 6px; text-align: left; }
.filter-checkbox { margin-right: 4px; }
.blinking-tooltip {
background: white;
color: black;
border: 1px solid #000;
border-radius: 4px;
padding: 2px 5px;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="legend">
<div id="filter-container"></div>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin></script>
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js" crossorigin></script>
<script>
(async function(){
// --- Load config ---
let config = {};
try {
const res = await fetch('/api/config');
config = await res.json();
} catch(err){ console.error('Failed to load config', err); }
const mapInterval = Number(config.site?.map_interval) || 3;
const bayAreaBounds = [
[Number(config.site?.map_top_left_lat), Number(config.site?.map_top_left_lon)],
[Number(config.site?.map_bottom_right_lat), Number(config.site?.map_bottom_right_lon)]
];
// --- Initialize map ---
const map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19 }).addTo(map);
map.fitBounds(bayAreaBounds);
// --- Utilities ---
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const colorMap = new Map(); let nextColorIndex=0;
function hashToColor(str){
if(colorMap.has(str)) return colorMap.get(str);
const color = palette[nextColorIndex % palette.length];
colorMap.set(str, color); nextColorIndex++;
return color;
}
function timeAgo(dateStr){
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);
if(d>0) return d+'d'; if(h>0) return h+'h'; if(m>0) return m+'m'; return s+'s';
}
function isInvalidCoord(node){ return !node || !node.last_lat || !node.last_long; }
// --- Load nodes ---
let nodes = [];
try {
const res = await fetch('/api/nodes');
const data = await res.json();
nodes = data.nodes || [];
} catch(err){ console.error('Failed to load nodes', err); }
const markers = {};
const markerById = {}; // Keyed by numeric node_id for packets
const nodeMap = new Map(); // Keyed by numeric node_id
const channels = new Set();
const activeBlinks = new Map();
const portMap = {1:"Text",67:"Telemetry",3:"Position",70:"Traceroute",4:"Node Info",71:"Neighbour Info",73:"Map Report"};
nodes.forEach(node=>{
if(isInvalidCoord(node)) return;
const lat = node.last_lat/1e7;
const lng = node.last_long/1e7;
const isRouter = node.role.toLowerCase().includes("router");
channels.add(node.channel);
nodeMap.set(node.node_id,node);
const color = hashToColor(node.channel);
const marker = L.circleMarker([lat,lng],{radius:isRouter?9:7,color:"white",fillColor:color,fillOpacity:1,weight:0.7}).addTo(map);
marker.nodeId = node.node_id;
marker.originalColor = color;
markerById[node.node_id]=marker;
let popupContent=`<b>${node.long_name} (${node.short_name})</b><br>
<b>Channel:</b> ${node.channel}<br>
<b>Model:</b> ${node.hw_model}<br>
<b>Role:</b> ${node.role}<br>`;
if(node.last_update) popupContent+=`<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`;
if(node.firmware) popupContent+=`<b>Firmware:</b> ${node.firmware}<br>`;
marker.on('click', e=>{
e.originalEvent.stopPropagation();
marker.bindPopup(popupContent).openPopup();
setTimeout(()=>marker.closePopup(),3000);
});
if(!markers[node.channel]) markers[node.channel]=[];
markers[node.channel].push({marker,isRouter});
});
// --- Filters ---
const filterContainer=document.getElementById('filter-container');
channels.forEach(channel=>{
const id=`filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
const color=hashToColor(channel);
const label=document.createElement('label');
label.style.color=color;
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${id}" checked> ${channel}`;
filterContainer.appendChild(label);
});
function updateMarkers(){
nodes.forEach(node=>{
const id=`filter-${node.channel.replace(/\s+/g,'-').toLowerCase()}`;
const checkbox=document.getElementById(id);
const marker=markerById[node.node_id];
if(marker) marker.setStyle({fillOpacity: checkbox.checked?1:0});
});
localStorage.setItem('meshview_map_filters', JSON.stringify({
channels: Array.from(channels).reduce((obj,c)=>{ obj[c]=document.getElementById(`filter-${c.replace(/\s+/g,'-').toLowerCase()}`).checked; return obj; },{})
}));
}
document.querySelectorAll(".filter-checkbox").forEach(input=>input.addEventListener("change",updateMarkers));
// Load saved filters
const savedFilters=JSON.parse(localStorage.getItem('meshview_map_filters')||'{}');
if(savedFilters.channels){
Object.keys(savedFilters.channels).forEach(c=>{
const checkbox=document.getElementById(`filter-${c.replace(/\s+/g,'-').toLowerCase()}`);
if(checkbox) checkbox.checked=savedFilters.channels[c];
});
}
updateMarkers();
// --- Packet blinking ---
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){
clearInterval(activeBlinks.get(marker));
marker.setStyle({fillColor: marker.originalColor});
if(marker.tooltip) map.removeLayer(marker.tooltip);
}
let count=0;
const portName=portMap[portnum]||`Port ${portnum}`;
const tooltip=L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
tooltip.addTo(map); marker.tooltip=tooltip;
const interval=setInterval(()=>{
if(map.hasLayer(marker)){
marker.setStyle({fillColor:count%2===0?'yellow':marker.originalColor});
marker.bringToFront();
}
count++;
if(count>7){ clearInterval(interval); marker.setStyle({fillColor:marker.originalColor}); map.removeLayer(tooltip); activeBlinks.delete(marker); }
},500);
activeBlinks.set(marker,interval);
}
let lastImportTime=null;
async function fetchLatestPacket(){
try{
const res=await fetch(`/api/packets?limit=1`);
const data=await res.json();
lastImportTime=data.packets?.[0]?.import_time || new Date().toISOString();
}catch(err){ console.error(err); }
}
async function fetchNewPackets(){
if(!lastImportTime) return;
try{
const res=await fetch(`/api/packets?since=${lastImportTime}`);
const data=await res.json();
if(!data.packets || !data.packets.length) return;
let latest=lastImportTime;
data.packets.forEach(packet=>{
if(packet.import_time && packet.import_time>latest) latest=packet.import_time;
const marker=markerById[packet.from_node_id];
const nodeData=nodeMap.get(packet.from_node_id);
if(marker && nodeData) blinkNode(marker,nodeData.long_name,packet.portnum);
});
lastImportTime=latest;
}catch(err){ console.error(err); }
}
if(mapInterval>0){ fetchLatestPacket(); setInterval(fetchNewPackets,mapInterval*1000); }
})();
</script>
</body>
</html>

View File

@@ -18,7 +18,6 @@
.legend-item { display: flex; align-items: center; margin-bottom: 4px; }
.legend-color { width: 16px; height: 16px; margin-right: 6px; border-radius: 4px; }
/* Floating pulse label style */
.pulse-label span {
background: rgba(0,0,0,0.6);
padding: 2px 4px;
@@ -35,166 +34,176 @@
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const map = L.map("map");
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(map);
const map = L.map("map");
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19, attribution: "© OpenStreetMap" }).addTo(map);
const nodeMarkers = new Map();
let lastPacketTime = null;
const nodeMarkers = new Map();
let lastPacketTime = null;
const portColors = {
1:"red",
67:"cyan",
3:"orange",
70:"purple",
4:"yellow",
71:"brown",
73:"pink"
};
const portLabels = {
1:"Text",
67:"Telemetry",
3:"Position",
70:"Traceroute",
4:"Node Info",
71:"Neighbour Info",
73:"Map Report"
};
function getPulseColor(portnum) { return portColors[portnum] || "green"; }
const portColors = {
1:"red",
67:"cyan",
3:"orange",
70:"purple",
4:"yellow",
71:"brown",
73:"pink"
};
const portLabels = {
1:"Text",
67:"Telemetry",
3:"Position",
70:"Traceroute",
4:"Node Info",
71:"Neighbour Info",
73:"Map Report"
};
function getPulseColor(portnum) { return portColors[portnum] || "green"; }
// Generate legend dynamically
const legend = document.getElementById("legend");
for (const [port, color] of Object.entries(portColors)) {
const item = document.createElement("div");
item.className = "legend-item";
const colorBox = document.createElement("div");
colorBox.className = "legend-color";
colorBox.style.background = color;
const label = document.createElement("span");
label.textContent = `${portLabels[port] || "Custom"} (${port})`;
item.appendChild(colorBox);
item.appendChild(label);
legend.appendChild(item);
}
// Legend
const legend = document.getElementById("legend");
for (const [port, color] of Object.entries(portColors)) {
const item = document.createElement("div");
item.className = "legend-item";
const colorBox = document.createElement("div");
colorBox.className = "legend-color";
colorBox.style.background = color;
const label = document.createElement("span");
label.textContent = `${portLabels[port] || "Custom"} (${port})`;
item.appendChild(colorBox);
item.appendChild(label);
legend.appendChild(item);
}
// Pulse marker with floating label on top
function pulseMarker(marker, highlightColor = "red") {
if (!marker) return;
if (marker.activePulse) return;
marker.activePulse = true;
// Pulse marker
function pulseMarker(marker, highlightColor = "red") {
if (!marker || marker.activePulse) return;
marker.activePulse = true;
const originalColor = marker.options.originalColor;
const originalRadius = marker.options.originalRadius;
marker.bringToFront();
const originalColor = marker.options.originalColor;
const originalRadius = marker.options.originalRadius;
marker.bringToFront();
const nodeInfo = marker.options.nodeInfo || {};
const portLabel = marker.currentPortLabel || "";
const displayName = `${nodeInfo.long_name || nodeInfo.short_name || "Unknown"}${portLabel ? ` (<i>${portLabel}</i>)` : ""}`;
const nodeInfo = marker.options.nodeInfo || {};
const portLabel = marker.currentPortLabel || "";
const displayName = `${nodeInfo.long_name || nodeInfo.short_name || "Unknown"}${portLabel ? ` (<i>${portLabel}</i>)` : ""}`;
marker.bindTooltip(displayName, {
permanent: true,
direction: 'top',
className: 'pulse-label',
offset: [0, -10],
html: true // Allow italics
}).openTooltip();
marker.bindTooltip(displayName, {
permanent: true,
direction: 'top',
className: 'pulse-label',
offset: [0, -10],
html: true
}).openTooltip();
const flashDuration = 2000, fadeDuration = 1000, flashInterval = 100, maxRadius = originalRadius + 5;
let flashTime = 0;
const flashDuration = 2000, fadeDuration = 1000, flashInterval = 100, maxRadius = originalRadius + 5;
let flashTime = 0;
const flashTimer = setInterval(() => {
flashTime += flashInterval;
const isOn = (flashTime / flashInterval) % 2 === 0;
marker.setStyle({ fillColor: isOn ? highlightColor : originalColor, radius: isOn ? maxRadius : originalRadius });
const flashTimer = setInterval(() => {
flashTime += flashInterval;
const isOn = (flashTime / flashInterval) % 2 === 0;
marker.setStyle({ fillColor: isOn ? highlightColor : originalColor, radius: isOn ? maxRadius : originalRadius });
if (flashTime >= flashDuration) {
clearInterval(flashTimer);
const fadeStart = performance.now();
function fade(now) {
const t = Math.min((now - fadeStart) / fadeDuration, 1);
const radius = originalRadius + (maxRadius - originalRadius) * (1 - t);
marker.setStyle({ fillColor: highlightColor, radius: radius, fillOpacity: 1 });
if (t < 1) requestAnimationFrame(fade);
else {
marker.setStyle({ fillColor: originalColor, radius: originalRadius, fillOpacity: 1 });
marker.unbindTooltip();
marker.activePulse = false;
}
if (flashTime >= flashDuration) {
clearInterval(flashTimer);
const fadeStart = performance.now();
function fade(now) {
const t = Math.min((now - fadeStart) / fadeDuration, 1);
const radius = originalRadius + (maxRadius - originalRadius) * (1 - t);
marker.setStyle({ fillColor: highlightColor, radius: radius, fillOpacity: 1 });
if (t < 1) requestAnimationFrame(fade);
else {
marker.setStyle({ fillColor: originalColor, radius: originalRadius, fillOpacity: 1 });
marker.unbindTooltip();
marker.activePulse = false;
}
requestAnimationFrame(fade);
}
}, flashInterval);
}
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
const nodes = (await res.json()).nodes;
nodes.forEach(node => {
const color = "blue";
const lat = node.last_lat, lng = node.last_long;
if(lat && lng) {
const marker = L.circleMarker([lat/1e7,lng/1e7], {
radius:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7
}).addTo(map);
marker.options.originalColor=color;
marker.options.originalRadius=7;
marker.options.nodeInfo=node;
marker.bindPopup(`<b>${node.long_name||node.short_name||"Unknown"}</b><br>ID: ${node.node_id}<br>Role: ${node.role}`);
nodeMarkers.set(node.node_id, marker);
} else {
nodeMarkers.set(node.node_id, {options:{nodeInfo:node}});
}
});
const markersWithCoords = Array.from(nodeMarkers.values()).filter(m=>m instanceof L.CircleMarker);
if(markersWithCoords.length>0) {
await setMapBoundsFromConfig();
} else {
map.setView([37.77,-122.42],9);
}
} catch(err){ console.error(err); }
}
async function setMapBoundsFromConfig() {
try {
const res = await fetch("/api/config");
const config = await res.json();
const topLeft = [ parseFloat(config.site.map_top_left_lat), parseFloat(config.site.map_top_left_lon) ];
const bottomRight = [ parseFloat(config.site.map_bottom_right_lat), parseFloat(config.site.map_bottom_right_lon) ];
map.fitBounds([topLeft, bottomRight]);
} catch(err) {
console.error("Failed to load map bounds from config:", err);
map.setView([37.77,-122.42],9);
requestAnimationFrame(fade);
}
}
}, flashInterval);
}
// --- Load nodes ---
async function loadNodes() {
try {
const res = await fetch("/api/nodes");
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
const nodes = data.nodes || [];
nodes.forEach(node => {
const lat = node.last_lat / 1e7;
const lng = node.last_long / 1e7;
if (lat && lng && !isNaN(lat) && !isNaN(lng)) {
const color = "blue";
const marker = L.circleMarker([lat,lng], {
radius:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7
}).addTo(map);
marker.options.originalColor = color;
marker.options.originalRadius = 7;
marker.options.nodeInfo = node;
marker.bindPopup(`<b>${node.long_name||node.short_name||"Unknown"}</b><br>ID: ${node.node_id}<br>Role: ${node.role}`);
nodeMarkers.set(node.node_id, marker);
} else {
nodeMarkers.set(node.node_id, {options:{nodeInfo:node}});
}
});
const markersWithCoords = Array.from(nodeMarkers.values()).filter(m=>m instanceof L.CircleMarker);
if(markersWithCoords.length>0) await setMapBoundsFromConfig();
else map.setView([37.77,-122.42],9);
} catch(err){
console.error("Failed to load nodes:", err);
}
}
// --- Map bounds ---
async function setMapBoundsFromConfig() {
try {
const res = await fetch("/api/config");
const config = await res.json();
const topLat = parseFloat(config.site.map_top_left_lat);
const topLon = parseFloat(config.site.map_top_left_lon);
const bottomLat = parseFloat(config.site.map_bottom_right_lat);
const bottomLon = parseFloat(config.site.map_bottom_right_lon);
if ([topLat, topLon, bottomLat, bottomLon].some(v => isNaN(v))) {
throw new Error("Map bounds contain NaN");
}
map.fitBounds([[topLat, topLon], [bottomLat, bottomLon]]);
} catch(err) {
console.error("Failed to load map bounds from config:", err);
map.setView([37.77,-122.42],9);
}
}
// --- Poll packets ---
async function pollPackets() {
try {
let url = "/api/packets?limit=10";
if (lastPacketTime) url += `&since=${lastPacketTime}`;
const packets = (await (await fetch(url)).json()).packets || [];
if (packets.length > 0) lastPacketTime = packets[0].import_time;
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP error ${res.status}`);
const data = await res.json();
const packets = data.packets || [];
if (packets.length) lastPacketTime = packets[0].import_time;
packets.forEach(pkt => {
const marker = nodeMarkers.get(pkt.from_node_id);
// 🔍 Debug log
const nodeName = marker?.options?.nodeInfo?.short_name || marker?.options?.nodeInfo?.long_name || "Unknown";
console.log(`Packet received: port=${pkt.portnum}, node=${nodeName}`);
if (marker instanceof L.CircleMarker) {
marker.currentPortLabel = portLabels[pkt.portnum] || `${pkt.portnum}`; // Save label
if (marker instanceof L.CircleMarker) { // only real markers
marker.currentPortLabel = portLabels[pkt.portnum] || `${pkt.portnum}`;
pulseMarker(marker, getPulseColor(pkt.portnum));
}
});
} catch (err) {
console.error(err);
} catch(err){
console.error("Failed to fetch packets:", err);
}
}
1
loadNodes().then(()=>{ setInterval(pollPackets,1000); });
// --- Initialize ---
loadNodes().then(() => setInterval(pollPackets, 1000));
</script>
</body>
</html>

View File

@@ -1,10 +1,12 @@
from sqlalchemy import select, func
from sqlalchemy.orm import lazyload
from meshview import database
from meshview.models import Packet, PacketSeen, Node, Traceroute
from sqlalchemy import text
from datetime import datetime, timedelta
from sqlalchemy import func, select, text
from sqlalchemy.orm import lazyload
from meshview import database
from meshview.models import Node, Packet, PacketSeen, Traceroute
async def get_node(node_id):
async with database.async_session() as session:
result = await session.execute(select(Node).where(Node.node_id == node_id))
@@ -27,9 +29,7 @@ async def get_packets(node_id=None, portnum=None, after=None, before=None, limit
q = select(Packet)
if node_id:
q = q.where(
(Packet.from_node_id == node_id) | (Packet.to_node_id == node_id)
)
q = q.where((Packet.from_node_id == node_id) | (Packet.to_node_id == node_id))
if portnum:
q = q.where(Packet.portnum == portnum)
if after:
@@ -47,15 +47,12 @@ async def get_packets(node_id=None, portnum=None, after=None, before=None, limit
return packets
async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
async with database.async_session() as session:
q = select(Packet)
if node_id:
q = q.where(
Packet.from_node_id == node_id
)
q = q.where(Packet.from_node_id == node_id)
if portnum:
q = q.where(Packet.portnum == portnum)
if since:
@@ -73,7 +70,13 @@ async def get_packet(packet_id):
async def get_uplinked_packets(node_id, portnum=None):
async with database.async_session() as session:
q = select(Packet).join(PacketSeen).where(PacketSeen.node_id == node_id).order_by(Packet.import_time.desc()).limit(500)
q = (
select(Packet)
.join(PacketSeen)
.where(PacketSeen.node_id == node_id)
.order_by(Packet.import_time.desc())
.limit(500)
)
if portnum:
q = q.where(Packet.portnum == portnum)
result = await session.execute(q)
@@ -93,18 +96,20 @@ async def get_packets_seen(packet_id):
async def has_packets(node_id, portnum):
async with database.async_session() as session:
return bool(
(await session.execute(
(
await session.execute(
select(Packet.id).where(Packet.from_node_id == node_id).limit(1)
)).scalar()
)
).scalar()
)
async def get_traceroute(packet_id):
async with database.async_session() as session:
result = await session.execute(
select(Traceroute)
.where(Traceroute.packet_id == packet_id)
.order_by(Traceroute.import_time)
select(Traceroute)
.where(Traceroute.packet_id == packet_id)
.order_by(Traceroute.import_time)
)
return result.scalars()
@@ -122,10 +127,10 @@ async def get_traceroutes(since):
yield tr
async def get_mqtt_neighbors(since):
async with database.async_session() as session:
result = await session.execute(select(PacketSeen, Packet)
result = await session.execute(
select(PacketSeen, Packet)
.join(Packet)
.where(
(PacketSeen.hop_limit == PacketSeen.hop_start)
@@ -148,6 +153,7 @@ async def get_total_packet_count():
result = await session.execute(q)
return result.scalar() # Return the total count of packets
# We count the total amount of seen packets
async def get_total_packet_seen_count():
async with database.async_session() as session:
@@ -156,7 +162,6 @@ async def get_total_packet_seen_count():
return result.scalar() # Return the` total count of seen packets
async def get_total_node_count(channel: str = None) -> int:
try:
async with database.async_session() as session:
@@ -177,7 +182,8 @@ async def get_total_node_count(channel: str = None) -> int:
async def get_top_traffic_nodes():
try:
async with database.async_session() as session:
result = await session.execute(text("""
result = await session.execute(
text("""
SELECT
n.node_id,
n.long_name,
@@ -192,18 +198,22 @@ async def get_top_traffic_nodes():
GROUP BY n.node_id, n.long_name, n.short_name
HAVING total_packets_sent > 0
ORDER BY total_times_seen DESC;
"""))
""")
)
rows = result.fetchall()
nodes = [{
'node_id': row[0],
'long_name': row[1],
'short_name': row[2],
'channel': row[3],
'total_packets_sent': row[4],
'total_times_seen': row[5]
} for row in rows]
nodes = [
{
'node_id': row[0],
'long_name': row[1],
'short_name': row[2],
'channel': row[3],
'total_packets_sent': row[4],
'total_times_seen': row[5],
}
for row in rows
]
return nodes
except Exception as e:
@@ -211,7 +221,6 @@ async def get_top_traffic_nodes():
return []
async def get_node_traffic(node_id: int):
try:
async with database.async_session() as session:
@@ -226,15 +235,19 @@ async def get_node_traffic(node_id: int):
AND packet.import_time >= DATETIME('now', 'localtime', '-24 hours')
GROUP BY packet.portnum
ORDER BY packet_count DESC;
"""), {"node_id": node_id}
"""),
{"node_id": node_id},
)
# Map the result to include node.long_name and packet data
traffic_data = [{
"long_name": row[0], # node.long_name
"portnum": row[1], # packet.portnum
"packet_count": row[2] # COUNT(*) as packet_count
} for row in result.all()]
traffic_data = [
{
"long_name": row[0], # node.long_name
"portnum": row[1], # packet.portnum
"packet_count": row[2], # COUNT(*) as packet_count
}
for row in result.all()
]
return traffic_data
@@ -244,7 +257,6 @@ async def get_node_traffic(node_id: int):
return []
async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
"""
Fetches nodes from the database based on optional filtering criteria.
@@ -259,7 +271,7 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
"""
try:
async with database.async_session() as session:
#print(channel) # Debugging output (consider replacing with logging)
# print(channel) # Debugging output (consider replacing with logging)
# Start with a base query selecting all nodes
query = select(Node)
@@ -286,7 +298,7 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
nodes = result.scalars().all()
return nodes # Return the list of nodes
except Exception as e:
except Exception:
print("error reading DB") # Consider using logging instead of print
return [] # Return an empty list in case of failure
@@ -297,7 +309,7 @@ async def get_packet_stats(
channel: str | None = None,
portnum: int | None = None,
to_node: int | None = None,
from_node: int | None = None
from_node: int | None = None,
):
now = datetime.now()
@@ -311,13 +323,10 @@ async def get_packet_stats(
raise ValueError("period_type must be 'hour' or 'day'")
async with database.async_session() as session:
q = (
select(
func.strftime(time_format, Packet.import_time).label('period'),
func.count().label('count')
)
.where(Packet.import_time >= start_time)
)
q = select(
func.strftime(time_format, Packet.import_time).label('period'),
func.count().label('count'),
).where(Packet.import_time >= start_time)
# Filters
if channel:
@@ -341,7 +350,7 @@ async def get_packet_stats(
"portnum": portnum,
"to_node": to_node,
"from_node": from_node,
"data": data
"data": data,
}

View File

@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en" data-bs-theme="dark">
<html lang="{{ site_config.get('general', {}).get('language', 'en') }}" data-bs-theme="dark">
<head>
<title>
Meshview - {{ site_config.get("site", {}).get("title", "") }}
@@ -9,32 +9,22 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- Scripts -->
<script src="https://unpkg.com/htmx.org@1.9.11" integrity="sha384-0gxUXCCR8yv9FM2b+U3FDbsKthCI66oH5IA9fHppQq9DDMHuMauqq1ZHBpJxQ0J0" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.11" crossorigin="anonymous"></script>
<script src="https://unpkg.com/htmx.org@1.9.11/dist/ext/sse.js" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" integrity="sha384-YvpcrYf0tY3lHB60NNkmXc5s9fDVZLESaAA55NDzOxhy9GkcIdslK1eN7N6jIeHz" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js" crossorigin="anonymous"></script>
<!-- Stylesheets -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet" crossorigin="anonymous">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
{% block head %}{% endblock %}
<style>
.htmx-indicator {
opacity: 0;
transition: opacity 500ms ease-in;
}
.htmx-request .htmx-indicator {
opacity: 1;
}
#search_form {
z-index: 4000;
}
#details_map {
width: 100%;
height: 500px;
}
.htmx-indicator { opacity: 0; transition: opacity 500ms ease-in; }
.htmx-request .htmx-indicator { opacity: 1; }
#search_form { z-index: 4000; }
#details_map { width: 100%; height: 500px; }
{% block css %}{% endblock %}
</style>
</head>
@@ -45,30 +35,105 @@
<div style="text-align:center">
<strong>{{ site.get("title", "") }} {{ site.get("domain", "") }}</strong>
</div>
<div style="text-align: center;">
<div style="text-align:center">
{{ site.get("message", "") }}
</div>
<!-- Menu -->
<div style="text-align:center">
{% if site.get("nodes") == "True" %}<a href="/nodelist">Nodes</a>{% endif %}
{% if site.get("conversations") == "True" %}&nbsp;-&nbsp;<a href="/chat">Conversations</a>{% endif %}
{% if site.get("everything") == "True" %}&nbsp;-&nbsp;<a href="/firehose">See <strong>everything</strong></a>{% endif %}
{% if site.get("graphs") == "True" %}&nbsp;-&nbsp;<a href="/nodegraph">Mesh Graphs</a>{% endif %}
{% if site.get("net") == "True" %}&nbsp;-&nbsp;<a href="/net">Weekly Net</a>{% endif %}
{% if site.get("map") == "True" %}&nbsp;-&nbsp;<a href="/map">Live Map</a>{% endif %}
{% if site.get("stats") == "True" %}&nbsp;-&nbsp;<a href="/stats">Stats</a>{% endif %}
{% if site.get("top") == "True" %}&nbsp;-&nbsp;<a href="/top">Top Traffic</a>{% endif %}
{% if site.get("nodes") == "True" %}<a href="/nodelist" id="nav-nodes" data-translate-lang="nodes">Nodes</a>{% endif %}
{% if site.get("conversations") == "True" %}&nbsp;-&nbsp;<a href="/chat" id="nav-conversations" data-translate-lang="conversations">Conversations</a>{% endif %}
{% if site.get("everything") == "True" %}&nbsp;-&nbsp;<a href="/firehose" id="nav-everything" data-translate-lang="everything">See Everything</a>{% endif %}
{% if site.get("graphs") == "True" %}&nbsp;-&nbsp;<a href="/nodegraph" id="nav-graph" data-translate-lang="graph">Mesh Graphs</a>{% endif %}
{% if site.get("net") == "True" %}&nbsp;-&nbsp;<a href="/net" id="nav-net" data-translate-lang="net">Weekly Net</a>{% endif %}
{% if site.get("map") == "True" %}&nbsp;-&nbsp;<a href="/map" id="nav-map" data-translate-lang="map">Live Map</a>{% endif %}
{% if site.get("stats") == "True" %}&nbsp;-&nbsp;<a href="/stats" id="nav-stats" data-translate-lang="stats">Stats</a>{% endif %}
{% if site.get("top") == "True" %}&nbsp;-&nbsp;<a href="/top" id="nav-top" data-translate-lang="top">Top Traffic Nodes</a>{% endif %}
</div>
{% include "search_form.html" %}
<!-- Search Form -->
<form class="container p-2 sticky-top mx-auto" id="search_form" action="/node_search">
<div class="row">
<input
class="col m-2"
id="q"
type="text"
name="q"
data-translate-lang="node id"
placeholder="Node id"
autocomplete="off"
list="node_options"
value="{{raw_node_id}}"
hx-trigger="input delay:100ms"
hx-get="/node_match"
hx-target="#node_options"
/>
<datalist id="node_options">
{% for option in node_options %}
<option value="{{option.id}}">{{option.id}} -- {{option.long_name}} ({{option.short_name}})</option>
{% endfor %}
</datalist>
<select name="portnum" class="col-2 m-2" id="portnum_select">
<!-- Options will be populated dynamically -->
</select>
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate-lang="go to node" />
</div>
</form>
{% block body %}{% endblock %}
<br>
<div style="text-align:center">
Visit <strong><a href="https://github.com/pablorevilla-meshtastic/meshview">Meshview</a></strong> on Github.
<small>ver. {{ SOFTWARE_RELEASE | default("unknown") }}</small>
</div>
<div style="text-align:center" id="footer" data-translate-lang="footer">
</div><div style="text-align:center"><div><small>ver. {{ SOFTWARE_RELEASE | default("unknown") }}</small></div>
<br>
<!-- Language Loader -->
<script>
async function loadTranslations() {
try {
const langCode = "{{ site_config.get('site', {}).get('language', 'en') }}";
const res = await fetch(`/api/lang?lang=${langCode}&section=base`);
const t = await res.json();
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.getAttribute("data-translate-lang");
if (t[key]) {
if (el.placeholder !== undefined && el.tagName === "INPUT" && el.type === "text") {
el.placeholder = t[key];
} else if (el.tagName === "INPUT" && el.type === "submit") {
el.value = t[key];
} else {
el.innerHTML = t[key];
}
}
});
// Portnum options
const select = document.getElementById("portnum_select");
if (select && t.portnum_options) {
select.innerHTML = ""; // Clear
const allOption = document.createElement("option");
allOption.value = "";
allOption.textContent = t["all"] || "All";
if ("{{portnum}}" === "") allOption.selected = true;
select.appendChild(allOption);
for (const [value, label] of Object.entries(t.portnum_options)) {
const opt = document.createElement("option");
opt.value = value;
opt.textContent = label;
if ("{{portnum}}" === String(value)) opt.selected = true;
select.appendChild(opt);
}
}
} catch (err) {
console.error("Failed to load language:", err);
}
}
document.addEventListener("DOMContentLoaded", loadTranslations);
</script>
</body>
</html>

View File

@@ -4,175 +4,150 @@
.timestamp {
min-width: 10em;
}
.chat-packet:nth-of-type(odd) {
background-color: #3a3a3a;
}
.chat-packet {
border-bottom: 1px solid #555;
padding: 8px;
border-radius: 8px;
}
.chat-packet:nth-of-type(even) {
background-color: #333333;
}
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
.chat-packet { border-bottom: 1px solid #555; padding: 8px; border-radius: 8px; }
.chat-packet:nth-of-type(even) { background-color: #333333; }
@keyframes flash {
0% { background-color: #ffe066; }
100% { background-color: inherit; }
}
.chat-packet.flash {
animation: flash 3.5s ease-out;
}
.chat-packet.flash { animation: flash 3.5s ease-out; }
/* Nested reply style below the message */
.replying-to {
font-size: 0.85em;
color: #aaa; /* gray text */
margin-top: 4px;
padding-left: 20px; /* increased indentation */
.replying-to .reply-preview {
color: #aaa;
}
}
/* Nested reply style */
.replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; }
.replying-to .reply-preview { color: #aaa; }
{% endblock %}
{% block body %}
<div id="chat-container">
<div class="container" id="chat-log">
</div>
<div class="container" id="chat-log"></div>
</div>
<script>
const chatContainer = document.querySelector("#chat-log");
let lastTime = null;
const renderedPacketIds = new Set();
const packetMap = new Map(); // store all packets weve seen
document.addEventListener("DOMContentLoaded", async () => {
const chatContainer = document.querySelector("#chat-log");
if (!chatContainer) return console.error("#chat-log not found");
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : text;
return div.innerHTML;
}
let lastTime = null;
const renderedPacketIds = new Set();
const packetMap = new Map();
let chatTranslations = {};
function renderPacket(packet, highlight = false) {
// prevent duplicates
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
packetMap.set(packet.id, packet);
function escapeHtml(text) {
const div = document.createElement("div");
div.textContent = text == null ? "" : text;
return div.innerHTML;
}
const date = new Date(packet.import_time);
const formattedTime = date.toLocaleTimeString([], {
hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true
});
const formattedDate = `${(date.getMonth() + 1).toString().padStart(2,"0")}/` +
`${date.getDate().toString().padStart(2,"0")}/` +
`${date.getFullYear()}`;
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
// 🔑 helper to apply translations
function applyTranslations(translations, root=document) {
root.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if (translations[key]) el.textContent = translations[key];
});
root.querySelectorAll("[data-translate-lang-title]").forEach(el => {
const key = el.dataset.translateLangTitle;
if (translations[key]) el.title = translations[key];
});
}
// Try to resolve the reply target
let replyHtml = "";
if (packet.reply_id) {
const parent = packetMap.get(packet.reply_id);
if (parent) {
replyHtml = `
<div class="replying-to">
<div class="reply-preview">
<i>Replying to: <strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
${escapeHtml(parent.payload || "")}</i>
</div>
</div>
function renderPacket(packet, highlight = false) {
if (renderedPacketIds.has(packet.id)) return;
renderedPacketIds.add(packet.id);
packetMap.set(packet.id, packet);
const date = new Date(packet.import_time);
const formattedTime = date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit", second: "2-digit", hour12: true });
const formattedDate = `${(date.getMonth()+1).toString().padStart(2,"0")}/${date.getDate().toString().padStart(2,"0")}/${date.getFullYear()}`;
const formattedTimestamp = `${formattedTime} - ${formattedDate}`;
let replyHtml = "";
if (packet.reply_id) {
const parent = packetMap.get(packet.reply_id);
if (parent) {
replyHtml = `
<div class="replying-to">
<div class="reply-preview">
<i data-translate-lang="replying_to"></i>
<strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
${escapeHtml(parent.payload || "")}
</div>
</div>
`;
} else {
replyHtml = `
<div class="replying-to">
<i data-translate-lang="replying_to"></i>
<a href="/packet/${packet.reply_id}">${packet.reply_id}</a>
</div>
`;
}
}
const div = document.createElement("div");
div.className = "row chat-packet" + (highlight ? " flash" : "");
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time}">${formattedTimestamp}</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" title="" data-translate-lang-title="view_packet_details">✉️</a>
${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/packet_list/${packet.from_node_id}">
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
</a>
</span>
<span class="col-5 message">${escapeHtml(packet.payload)}${replyHtml}</span>
`;
} else {
// fallback if parent not loaded yet
replyHtml = `
<div class="replying-to">
<i>Replying to: <a href="/packet/${packet.reply_id}">${packet.reply_id}</a></i>
</div>
`;
}
chatContainer.prepend(div);
// Apply translations to the newly added packet
applyTranslations(chatTranslations, div);
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
}
const div = document.createElement("div");
div.className = "row chat-packet" + (highlight ? " flash" : "");
div.dataset.packetId = packet.id;
div.innerHTML = `
<span class="col-2 timestamp" title="${packet.import_time}">
${formattedTimestamp}
</span>
<span class="col-2 channel">
<a href="/packet/${packet.id}" title="View packet details">✉️</a> ${escapeHtml(packet.channel || "")}
</span>
<span class="col-3 nodename">
<a href="/packet_list/${packet.from_node_id}">
${escapeHtml((packet.long_name || "").trim() || `Node ${packet.from_node_id}`)}
</a>
</span>
<span class="col-5 message">
${escapeHtml(packet.payload)}
${replyHtml}
</span>
`;
// Prepend so newest messages are at the top.
chatContainer.prepend(div);
if (highlight) setTimeout(() => div.classList.remove("flash"), 2500);
}
function renderPacketsEnsureDescending(packets, highlight = false) {
if (!Array.isArray(packets) || packets.length === 0) return;
const sortedDesc = packets.slice().sort((a, b) =>
new Date(b.import_time) - new Date(a.import_time)
);
for (let i = sortedDesc.length - 1; i >= 0; i--) {
renderPacket(sortedDesc[i], highlight);
}
}
async function fetchInitial() {
try {
const url = new URL("/api/chat", window.location.origin);
url.searchParams.set("limit", "100");
const resp = await fetch(url);
const data = await resp.json();
if (data && data.packets && data.packets.length > 0) {
renderPacketsEnsureDescending(data.packets, false);
if (data.latest_import_time) lastTime = data.latest_import_time;
function renderPacketsEnsureDescending(packets, highlight = false) {
if (!Array.isArray(packets) || packets.length === 0) return;
const sortedDesc = packets.slice().sort((a,b) => new Date(b.import_time)-new Date(a.import_time));
for (let i = sortedDesc.length-1; i>=0; i--) renderPacket(sortedDesc[i], highlight);
}
} catch (err) {
console.error("Initial fetch error:", err);
}
}
async function fetchUpdates() {
try {
const url = new URL("/api/chat", window.location.origin);
url.searchParams.set("limit", "100");
if (lastTime) url.searchParams.set("since", lastTime);
const resp = await fetch(url);
const data = await resp.json();
if (data && data.packets && data.packets.length > 0) {
renderPacketsEnsureDescending(data.packets, true);
if (data.latest_import_time) lastTime = data.latest_import_time;
async function fetchInitial() {
try {
const resp = await fetch("/api/chat?limit=100");
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets);
lastTime = data?.latest_import_time || lastTime;
} catch(err) { console.error("Initial fetch error:", err); }
}
} catch (err) {
console.error("Fetch updates error:", err);
}
}
// initial load
fetchInitial();
setInterval(fetchUpdates, 5000);
async function fetchUpdates() {
try {
const url = new URL("/api/chat", window.location.origin);
url.searchParams.set("limit","100");
if(lastTime) url.searchParams.set("since", lastTime);
const resp = await fetch(url);
const data = await resp.json();
if (data?.packets?.length) renderPacketsEnsureDescending(data.packets, true);
lastTime = data?.latest_import_time || lastTime;
} catch(err){ console.error("Fetch updates error:", err); }
}
async function loadTranslations() {
try {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
const res = await fetch(`/api/lang?lang=${langCode}&section=chat`);
chatTranslations = await res.json();
applyTranslations(chatTranslations, document);
} catch(err){ console.error("Chat translation load failed:", err); }
}
await loadTranslations();
await fetchInitial();
setInterval(fetchUpdates, 5000);
});
</script>
{% endblock %}

View File

@@ -43,6 +43,22 @@
#share-button:active {
background-color: #3d8b40;
}
#reset-filters-button {
margin-left: 10px;
padding: 5px 15px;
background-color: #f44336;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
#reset-filters-button:hover {
background-color: #da190b;
}
#reset-filters-button:active {
background-color: #c41e0d;
}
.blinking-tooltip {
background: white;
color: black;
@@ -56,10 +72,11 @@
{% block body %}
<div id="map" style="width: 100%; height: calc(100vh - 270px)"></div>
<div id="filter-container">
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> Show Routers Only
<input type="checkbox" class="filter-checkbox" id="filter-routers-only"> <span id="filter-routers-label">Show Routers Only</span>
</div>
<div style="text-align: center; margin-top: 5px;">
<button id="share-button" onclick="shareCurrentView()">🔗 Share This View</button>
<button id="share-button">🔗 Share This View</button>
<button id="reset-filters-button" onclick="resetFiltersToDefaults()">↺ Reset Filters To Defaults</button>
</div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
@@ -70,6 +87,21 @@
crossorigin></script>
<script>
async function loadTranslations() {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
try {
const res = await fetch(`/api/lang?lang=${langCode}&section=map`);
window.mapTranslations = await res.json();
} catch(err) {
console.error("Map translation load failed:", err);
window.mapTranslations = {};
}
}
// Initialize map AFTER translations are loaded
loadTranslations().then(() => {
const t = window.mapTranslations || {};
// ---- Map Setup ----
var map = L.map('map');
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
@@ -77,13 +109,8 @@
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
// Custom view from URL parameters
{% if custom_view %}
var customView = {
lat: {{ custom_view.lat }},
lng: {{ custom_view.lng }},
zoom: {{ custom_view.zoom }}
};
var customView = { lat: {{ custom_view.lat }}, lng: {{ custom_view.lng }}, zoom: {{ custom_view.zoom }} };
{% else %}
var customView = null;
{% endif %}
@@ -94,8 +121,8 @@
var nodes = [
{% for node in nodes %}
{
lat: {{ ((node.last_lat / 10**7) + (range(-9,9) | random) / 30000) | round(7) }},
long: {{ ((node.last_long / 10**7) + (range(-9,9) | random) / 30000) | round(7) if node.last_long is not none else "null" }},
lat: {{ ((node.last_lat / 10**7) + (range(-9,9) | random) / 10000) | round(7) }},
long: {{ ((node.last_long / 10**7) + (range(-9,9) | random) / 10000) | round(7) if node.last_long is not none else "null" }},
long_name: {{ (node.long_name or "") | tojson }},
short_name: {{ (node.short_name or "") | tojson }},
channel: {{ (node.channel or "") | tojson }},
@@ -127,7 +154,6 @@
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
const colorMap = new Map();
let nextColorIndex = 0;
function hashToColor(str) {
if (colorMap.has(str)) return colorMap.get(str);
const color = palette[nextColorIndex % palette.length];
@@ -138,12 +164,7 @@
const nodeMap = new Map();
nodes.forEach(n => nodeMap.set(n.id, n));
function isInvalidCoord(node) {
if (!node) return true;
let {lat, long} = node;
return !lat || !long || lat === 0 || long === 0 || Number.isNaN(lat) || Number.isNaN(long);
}
function isInvalidCoord(node) { return !node || !node.lat || !node.long || node.lat===0 || node.long===0 || Number.isNaN(node.lat) || Number.isNaN(node.long); }
// ---- Marker Plotting ----
var bounds = L.latLngBounds();
@@ -155,74 +176,213 @@
channels.add(category);
let color = hashToColor(category);
let markerOptions = { radius: node.isRouter ? 9 : 7, color: "white", fillColor: color, fillOpacity: 1, weight: 0.7 };
let popupContent = `<b><a href="/packet_list/${node.id}">${node.long_name}</a> (${node.short_name})</b><br>
<b>Channel:</b> ${node.channel}<br>
<b>Model:</b> ${node.hw_model}<br>
<b>Role:</b> ${node.role}<br>`;
if (node.last_update) popupContent += `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`;
if (node.firmware) popupContent += `<b>Firmware:</b> ${node.firmware}<br>`;
<b>${t.channel||'Channel:'}</b> ${node.channel}<br>
<b>${t.model||'Model:'}</b> ${node.hw_model}<br>
<b>${t.role||'Role:'}</b> ${node.role}<br>`;
if (node.last_update) popupContent += `<b>${t.last_seen||'Last seen:'}</b> ${timeAgo(node.last_update)}<br>`;
if (node.firmware) popupContent += `<b>${t.firmware||'Firmware:'}</b> ${node.firmware}<br>`;
var marker = L.circleMarker([node.lat, node.long], markerOptions).addTo(map);
var marker = L.circleMarker([node.lat, node.long], { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 }).addTo(map);
marker.nodeId = node.id;
marker.originalColor = color;
markerById[node.id] = marker;
marker.on('click', function(e) {
marker.on('click', e => {
e.originalEvent.stopPropagation();
marker.bindPopup(popupContent).openPopup();
setTimeout(() => marker.closePopup(), 3000);
onNodeClick(node);
});
if (!markers[category]) markers[category] = [];
markers[category].push({ marker, isRouter: node.isRouter });
if (!markers[category]) markers[category]=[];
markers[category].push({marker,isRouter:node.isRouter});
bounds.extend(marker.getLatLng());
}
});
var bayAreaBounds = [
// ---- Map bounds ----
var areaBounds = [
[{{ site_config["site"]["map_top_left_lat"] }}, {{ site_config["site"]["map_top_left_lon"] }}],
[{{ site_config["site"]["map_bottom_right_lat"] }}, {{ site_config["site"]["map_bottom_right_lon"] }}]
];
// Apply custom view or default bounds
if (customView) {
map.setView([customView.lat, customView.lng], customView.zoom);
} else {
map.fitBounds(bayAreaBounds);
if (customView) map.setView([customView.lat,customView.lng],customView.zoom);
else map.fitBounds(areaBounds);
// ---- LocalStorage for Filter Preferences ----
const FILTER_STORAGE_KEY = 'meshview_map_filters';
function getDefaultFilters() {
return {
routersOnly: false,
channels: {}
};
}
function saveFiltersToLocalStorage() {
const filters = {
routersOnly: document.getElementById("filter-routers-only").checked,
channels: {}
};
channelList.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox) {
filters.channels[channel] = checkbox.checked;
}
});
localStorage.setItem(FILTER_STORAGE_KEY, JSON.stringify(filters));
console.log('Filters saved to localStorage:', filters);
}
function loadFiltersFromLocalStorage() {
try {
const stored = localStorage.getItem(FILTER_STORAGE_KEY);
if (stored) {
const filters = JSON.parse(stored);
console.log('Filters loaded from localStorage:', filters);
return filters;
}
} catch (error) {
console.error('Error loading filters from localStorage:', error);
}
return null;
}
function renderChannelFilters(savedFilters) {
const filterContainer = document.getElementById("filter-container");
filterContainer.querySelectorAll('label[data-channel-filter="true"]').forEach(el => el.remove());
channelList.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
let color = hashToColor(channel);
let label = document.createElement('label');
label.style.color = color;
label.setAttribute('data-channel-filter', 'true');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'filter-checkbox';
checkbox.id = filterId;
const shouldCheck = savedFilters ? savedFilters.channels?.[channel] !== false : true;
checkbox.checked = shouldCheck;
checkbox.addEventListener("change", updateMarkers);
label.appendChild(checkbox);
label.append(` ${channel}`);
filterContainer.appendChild(label);
});
}
function resetFiltersToDefaults() {
localStorage.removeItem(FILTER_STORAGE_KEY);
console.log('Filters reset to defaults');
// Reset routers only filter
document.getElementById("filter-routers-only").checked = false;
// Reset all channel filters to checked (default)
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox) {
checkbox.checked = true;
}
});
updateMarkers();
const button = document.getElementById('reset-filters-button');
const originalText = button.textContent;
button.textContent = '✓ Filters Reset!';
button.style.backgroundColor = '#2196F3';
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = '#f44336';
}, 2000);
}
// ---- Filters ----
const filterLabel = document.getElementById("filter-routers-label");
filterLabel.textContent = t.show_routers_only || "Show Routers Only";
let filterContainer = document.getElementById("filter-container");
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let filterId = `filter-${channel.replace(/\s+/g,'-').toLowerCase()}`;
let color = hashToColor(channel);
let label = document.createElement('label');
label.style.color = color;
label.innerHTML = `<input type="checkbox" class="filter-checkbox" id="${filterId}" checked> ${channel}`;
label.style.color=color;
label.innerHTML=`<input type="checkbox" class="filter-checkbox" id="${filterId}" checked> ${channel}`;
filterContainer.appendChild(label);
});
// Load saved filters from localStorage
const savedFilters = loadFiltersFromLocalStorage();
if (savedFilters) {
// Apply routers only filter
document.getElementById("filter-routers-only").checked = savedFilters.routersOnly || false;
// Apply channel filters
channels.forEach(channel => {
let filterId = `filter-${channel.replace(/\s+/g, '-').toLowerCase()}`;
let checkbox = document.getElementById(filterId);
if (checkbox && savedFilters.channels.hasOwnProperty(channel)) {
checkbox.checked = savedFilters.channels[channel];
}
});
}
function updateMarkers() {
let showRoutersOnly = document.getElementById("filter-routers-only").checked;
nodes.forEach(node => {
let category = node.channel;
let checkbox = document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
let shouldShow = checkbox.checked && (!showRoutersOnly || node.isRouter);
let marker = markerById[node.id];
if (marker) marker.setStyle({ fillOpacity: shouldShow ? 1 : 0 });
let category=node.channel;
let checkbox=document.getElementById(`filter-${category.replace(/\s+/g,'-').toLowerCase()}`);
let shouldShow=checkbox.checked && (!showRoutersOnly || node.isRouter);
let marker=markerById[node.id];
if(marker) marker.setStyle({fillOpacity:shouldShow?1:0});
});
// Save filters to localStorage whenever they change
saveFiltersToLocalStorage();
}
function getActiveChannels() {
return channelList.filter(channel => {
if (channel === 'Unknown') return false;
let checkbox = document.getElementById(`filter-${channel.replace(/\s+/g,'-').toLowerCase()}`);
return checkbox ? checkbox.checked : true;
});
}
document.querySelectorAll(".filter-checkbox").forEach(input => input.addEventListener("change", updateMarkers));
// Apply initial filters (from localStorage or defaults)
updateMarkers();
// ---- Share button ----
const shareBtn = document.getElementById("share-button");
shareBtn.textContent = `🔗 ${t.share_view || "Share This View"}`;
shareBtn.onclick = function() {
const center = map.getCenter();
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
navigator.clipboard.writeText(shareUrl).then(()=>{
const orig = shareBtn.textContent;
shareBtn.textContent = '✓ Link Copied!';
shareBtn.style.backgroundColor='#2196F3';
setTimeout(()=>{ shareBtn.textContent=orig; shareBtn.style.backgroundColor='#4CAF50'; },2000);
}).catch(()=>{ alert('Share this link:\n'+shareUrl); });
};
// ---- Edges ----
var edgeLayer = L.layerGroup().addTo(map);
var edgesData = null;
let selectedNodeId = null;
fetch('/api/edges').then(res => res.json()).then(data => { edgesData = data.edges; }).catch(err => console.error(err));
fetch('/api/edges')
.then(r => r.json())
.then(data => edgesData = data.edges)
.catch(err => console.error(err));
function onNodeClick(node) {
if (selectedNodeId != node.id) {
@@ -231,180 +391,120 @@
if (!edgesData) return;
if (!map.hasLayer(edgeLayer)) edgeLayer.addTo(map);
edgesData.forEach(edge => {
if (edge.from !== node.id && edge.to !== node.id) return;
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) return;
if (isInvalidCoord(fromNode) || isInvalidCoord(toNode)) return;
edgesData.forEach(edge => {
if (edge.from !== node.id && edge.to !== node.id) return;
const fromNode = nodeMap.get(edge.from);
const toNode = nodeMap.get(edge.to);
if (!fromNode || !toNode) return;
if (isInvalidCoord(fromNode) || isInvalidCoord(toNode)) return;
const lineColor = edge.type === "neighbor" ? "darkred" : "black";
const dash = edge.type === "traceroute" ? "5,5" : null;
const weight = edge.type === "neighbor" ? 3 : 2;
const lineColor = edge.type === "neighbor" ? "gray" : "orange";
const weight = 3;
const polyline = L.polyline([[fromNode.lat, fromNode.long],[toNode.lat, toNode.long]], { color: lineColor, weight, opacity: 1, dashArray: dash }).addTo(edgeLayer).bringToFront();
const polyline = L.polyline(
[[fromNode.lat, fromNode.long], [toNode.lat, toNode.long]],
{ color: lineColor, weight, opacity: 1 }
).addTo(edgeLayer).bringToFront();
if (edge.type === "traceroute") {
L.polylineDecorator(polyline, {
patterns: [{ offset: '100%', repeat: 0, symbol: L.Symbol.arrowHead({ pixelSize: 5, polygon: false, pathOptions: { stroke: true, color: lineColor } }) }]
}).addTo(edgeLayer);
// ✅ Show tooltip right where the user clicks
polyline.on('click', e => {
const tooltip = L.tooltip({
permanent: false,
direction: 'top',
offset: [0, -5],
className: 'blinking-tooltip'
})
.setContent(edge.type.charAt(0).toUpperCase() + edge.type.slice(1))
.setLatLng(e.latlng)
.addTo(map);
setTimeout(() => map.removeLayer(tooltip), 3000);
});
if (edge.type === "traceroute") {
L.polylineDecorator(polyline, {
patterns: [{
offset: '100%',
repeat: 0,
symbol: L.Symbol.arrowHead({
pixelSize: 5,
polygon: false,
pathOptions: { stroke: true, color: lineColor }
})
}]
}).addTo(edgeLayer);
}
});
}
}
map.on('click', e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; }});
// ---- Blinking ----
var activeBlinks=new Map();
function blinkNode(marker,longName,portnum){
if(!map.hasLayer(marker)) return;
if(activeBlinks.has(marker)){
clearInterval(activeBlinks.get(marker));
marker.setStyle({fillColor:marker.originalColor});
if(marker.tooltip) map.removeLayer(marker.tooltip);
}
let blinkCount=0;
let portName=portMap[portnum]||`Port ${portnum}`;
let tooltip=L.tooltip({permanent:true,direction:'top',offset:[0,-marker.options.radius-5],className:'blinking-tooltip'})
.setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
tooltip.addTo(map);
marker.tooltip=tooltip;
let interval=setInterval(()=>{
if(map.hasLayer(marker)){
marker.setStyle({fillColor:blinkCount%2===0?'yellow':marker.originalColor});
marker.bringToFront();
}
blinkCount++;
if(blinkCount>7){
clearInterval(interval);
marker.setStyle({fillColor:marker.originalColor});
map.removeLayer(tooltip);
activeBlinks.delete(marker);
}
},500);
activeBlinks.set(marker,interval);
}
// ---- Packet fetching ----
let lastImportTime=null;
const mapInterval={{ site_config["site"]["map_interval"]|default(3) }};
function fetchLatestPacket(){
fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{
if(data.packets && data.packets.length>0) lastImportTime=data.packets[0].import_time;
else lastImportTime=new Date().toISOString();
}).catch(err=>console.error(err));
}
function fetchNewPackets(){
if(!lastImportTime) return;
fetch(`/api/packets?since=${lastImportTime}`).then(r=>r.json()).then(data=>{
if(!data.packets||data.packets.length===0) return;
let latestSeen=lastImportTime;
data.packets.forEach(packet=>{
if(packet.import_time && (!latestSeen || packet.import_time>latestSeen)) latestSeen=packet.import_time;
let marker=markerById[packet.from_node_id];
if(marker){
let nodeData=nodeMap.get(packet.from_node_id);
if(nodeData) blinkNode(marker,nodeData.long_name,packet.portnum);
}
});
}
if(latestSeen) lastImportTime=latestSeen;
}).catch(err=>console.error(err));
}
map.on('click', function(e) {
if (!e.originalEvent.target.classList.contains('leaflet-interactive')) {
edgeLayer.clearLayers();
selectedNodeId = null;
}
});
// ---- Blinking Nodes ----
var activeBlinks = new Map();
function blinkNode(marker, longName, portnum) {
if (!map.hasLayer(marker)) return;
if (activeBlinks.has(marker)) {
clearInterval(activeBlinks.get(marker));
marker.setStyle({ fillColor: marker.originalColor });
if (marker.tooltip) map.removeLayer(marker.tooltip);
}
let blinkCount = 0;
let portName = portMap[portnum] || `Port ${portnum}`;
let tooltip = L.tooltip({
permanent: true,
direction: 'top',
offset: [0, -marker.options.radius - 5],
className: 'blinking-tooltip'
}).setContent(`${longName} (${portName})`).setLatLng(marker.getLatLng());
tooltip.addTo(map);
marker.tooltip = tooltip;
let interval = setInterval(() => {
if (map.hasLayer(marker)) {
// Alternate color
marker.setStyle({ fillColor: blinkCount % 2 === 0 ? 'yellow' : marker.originalColor });
// Bring marker to top
marker.bringToFront();
}
blinkCount++;
if (blinkCount > 7) {
clearInterval(interval);
marker.setStyle({ fillColor: marker.originalColor });
map.removeLayer(tooltip);
activeBlinks.delete(marker);
}
}, 500);
activeBlinks.set(marker, interval);
}
// ---- Packet Fetching ----
let lastImportTime = null;
function fetchLatestPacket() {
fetch(`/api/packets?limit=1`)
.then(res => res.json())
.then(data => {
if (data.packets && data.packets.length > 0) {
lastImportTime = data.packets[0].import_time;
console.log("Initial lastImportTime:", lastImportTime);
} else {
lastImportTime = new Date().toISOString();
console.log("No packets, setting lastImportTime to now:", lastImportTime);
}
})
.catch(err => console.error("Error fetching latest packet:", err));
}
function fetchNewPackets() {
if (!lastImportTime) return;
fetch(`/api/packets?since=${lastImportTime}`)
.then(res => res.json())
.then(data => {
console.log("===== New Fetch =====");
if (!data.packets || data.packets.length === 0) {
console.log("No new packets");
return;
}
let latestSeen = lastImportTime;
data.packets.forEach(packet => {
console.log(`Packet ID: ${packet.id}, From Node: ${packet.from_node_id}, Port: ${packet.portnum}, Time: ${packet.import_time}`);
if (packet.import_time && (!latestSeen || packet.import_time > latestSeen)) latestSeen = packet.import_time;
let marker = markerById[packet.from_node_id];
if (marker) {
let nodeData = nodeMap.get(packet.from_node_id);
if (nodeData) blinkNode(marker, nodeData.long_name, packet.portnum);
}
});
if (latestSeen) lastImportTime = latestSeen;
console.log("Updated lastImportTime:", lastImportTime);
console.log("===== End Fetch =====");
})
.catch(err => console.error("Fetch error:", err));
}
// ---- Polling Control ----
let packetInterval = null;
const mapInterval = {{ site_config["site"]["map_interval"] | default(3) }};
function startPacketFetcher() {
if (mapInterval <= 0) return;
if (!packetInterval) {
fetchLatestPacket();
packetInterval = setInterval(fetchNewPackets, mapInterval * 1000);
console.log("Packet fetcher started, interval:", mapInterval, "seconds");
}
}
function stopPacketFetcher() {
if (packetInterval) {
clearInterval(packetInterval);
packetInterval = null;
console.log("Packet fetcher stopped");
}
}
document.addEventListener("visibilitychange", function() {
if (document.hidden) stopPacketFetcher();
else startPacketFetcher();
});
// ---- Share Current View ----
function shareCurrentView() {
const center = map.getCenter();
const zoom = map.getZoom();
const lat = center.lat.toFixed(6);
const lng = center.lng.toFixed(6);
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
// Copy to clipboard
navigator.clipboard.writeText(shareUrl).then(() => {
const button = document.getElementById('share-button');
const originalText = button.textContent;
button.textContent = '✓ Link Copied!';
button.style.backgroundColor = '#2196F3';
setTimeout(() => {
button.textContent = originalText;
button.style.backgroundColor = '#4CAF50';
}, 2000);
}).catch(err => {
// Fallback for older browsers
alert('Share this link:\n' + shareUrl);
});
}
// ---- Initialize ----
if (mapInterval > 0) startPacketFetcher();
let packetInterval=null;
function startPacketFetcher(){ if(mapInterval<=0) return; if(!packetInterval){ fetchLatestPacket(); packetInterval=setInterval(fetchNewPackets,mapInterval*1000); } }
function stopPacketFetcher(){ if(packetInterval){ clearInterval(packetInterval); packetInterval=null; } }
document.addEventListener("visibilitychange",function(){ if(document.hidden) stopPacketFetcher(); else startPacketFetcher(); });
if(mapInterval>0) startPacketFetcher();
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -19,9 +19,11 @@
{% block body %}
<div class="container">
{{ site_config["site"]["weekly_net_message"] }} <br><br>
<span>{{ site_config["site"]["weekly_net_message"] }}</span> <br><br>
<h5>Number of Check-ins: {{ packets|length }}</h5>
<h5>
<span data-translate-lang="number_of_checkins">Number of Check-ins:</span> {{ packets|length }}
</h5>
</div>
<div class="container">
@@ -48,7 +50,26 @@
</span>
</div>
{% else %}
No packets found.
<span data-translate-lang="no_packets_found">No packets found.</span>
{% endfor %}
</div>
<script>
async function loadTranslations() {
try {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
const res = await fetch(`/api/lang?lang=${langCode}&section=net`);
const translations = await res.json();
document.querySelectorAll("[data-translate-lang]").forEach(el => {
const key = el.dataset.translateLang;
if(el.placeholder !== undefined && el.placeholder !== "") el.placeholder = translations[key] || el.placeholder;
else el.textContent = translations[key] || el.textContent;
});
} catch(err) {
console.error("Net translations load failed:", err);
}
}
document.addEventListener("DOMContentLoaded", loadTranslations());
</script>
{% endblock %}

View File

@@ -17,7 +17,7 @@
position: absolute;
bottom: 100px;
left: 10px;
z-index: 10;
z-index: 10;1
display: flex;
flex-direction: column;
gap: 5px;

View File

@@ -84,6 +84,36 @@ select, .export-btn, .search-box, .clear-btn {
font-weight: bold;
color: white;
}
.favorite-star {
cursor: pointer;
font-size: 1.2em;
user-select: none;
transition: color 0.2s;
}
.favorite-star:hover {
transform: scale(1.2);
}
.favorite-star.active {
color: #ffd700;
}
.favorites-btn {
background-color: #ffd700;
color: #000;
border: none;
}
.favorites-btn:hover {
background-color: #ffed4e;
}
.favorites-btn.active {
background-color: #ff6b6b;
color: white;
}
{% endblock %}
{% block body %}
@@ -106,6 +136,7 @@ select, .export-btn, .search-box, .clear-btn {
<option value="">All Firmware</option>
</select>
<button class="favorites-btn" id="favorites-btn">⭐ Show Favorites</button>
<button class="export-btn" id="export-btn">Export CSV</button>
<button class="clear-btn" id="clear-btn">Clear Filters</button>
</div>
@@ -127,6 +158,7 @@ select, .export-btn, .search-box, .clear-btn {
<th>Last Longitude <span class="sort-icon"></span></th>
<th>Channel <span class="sort-icon"></span></th>
<th>Last Update <span class="sort-icon"></span></th>
<th>Favorite</th>
</tr>
</thead>
<tbody id="node-table-body">
@@ -139,11 +171,38 @@ select, .export-btn, .search-box, .clear-btn {
let allNodes = [];
let sortColumn = "short_name"; // default sorted column
let sortAsc = true; // default ascending
let showOnlyFavorites = false;
// Declare headers and keyMap BEFORE any function that uses them
const headers = document.querySelectorAll("thead th");
const keyMap = ["short_name","long_name","hw_model","firmware","role","last_lat","last_long","channel","last_update"];
// LocalStorage functions for favorites
function getFavorites() {
const favorites = localStorage.getItem('nodelist_favorites');
return favorites ? JSON.parse(favorites) : [];
}
function saveFavorites(favorites) {
localStorage.setItem('nodelist_favorites', JSON.stringify(favorites));
}
function toggleFavorite(nodeId) {
let favorites = getFavorites();
const index = favorites.indexOf(nodeId);
if (index > -1) {
favorites.splice(index, 1);
} else {
favorites.push(nodeId);
}
saveFavorites(favorites);
applyFilters();
}
function isFavorite(nodeId) {
return getFavorites().includes(nodeId);
}
document.addEventListener("DOMContentLoaded", async function() {
const tbody = document.getElementById("node-table-body");
const roleFilter = document.getElementById("role-filter");
@@ -154,6 +213,7 @@ document.addEventListener("DOMContentLoaded", async function() {
const countSpan = document.getElementById("node-count");
const exportBtn = document.getElementById("export-btn");
const clearBtn = document.getElementById("clear-btn");
const favoritesBtn = document.getElementById("favorites-btn");
try {
const response = await fetch("/api/nodes?days_active=3");
@@ -174,6 +234,31 @@ document.addEventListener("DOMContentLoaded", async function() {
searchBox.addEventListener("input", applyFilters);
exportBtn.addEventListener("click", exportToCSV);
clearBtn.addEventListener("click", clearFilters);
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
// Use event delegation for star clicks
tbody.addEventListener("click", (e) => {
if (e.target.classList.contains('favorite-star')) {
const nodeId = parseInt(e.target.getAttribute('data-node-id'));
// Get current favorites
let favorites = getFavorites();
const index = favorites.indexOf(nodeId);
const isNowFavorite = index === -1; // Will it be a favorite after toggle?
// Update the star immediately for instant feedback
if (isNowFavorite) {
e.target.classList.add('active');
e.target.textContent = '★';
} else {
e.target.classList.remove('active');
e.target.textContent = '☆';
}
// Save to localStorage
toggleFavorite(nodeId);
}
});
headers.forEach((th, index) => {
th.addEventListener("click", () => {
@@ -212,6 +297,18 @@ document.addEventListener("DOMContentLoaded", async function() {
});
}
function toggleFavoritesFilter() {
showOnlyFavorites = !showOnlyFavorites;
if (showOnlyFavorites) {
favoritesBtn.textContent = "⭐ Show All";
favoritesBtn.classList.add("active");
} else {
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
}
applyFilters();
}
function applyFilters() {
const searchTerm = searchBox.value.trim().toLowerCase();
@@ -227,7 +324,9 @@ document.addEventListener("DOMContentLoaded", async function() {
(node.node_id && node.node_id.toString().includes(searchTerm)) ||
(node.id && node.id.toString().includes(searchTerm));
return roleMatch && channelMatch && hwMatch && firmwareMatch && searchMatch;
const favoriteMatch = !showOnlyFavorites || isFavorite(node.node_id);
return roleMatch && channelMatch && hwMatch && firmwareMatch && searchMatch && favoriteMatch;
});
if (sortColumn) {
@@ -241,10 +340,14 @@ document.addEventListener("DOMContentLoaded", async function() {
function renderTable(nodes) {
tbody.innerHTML = "";
if (!nodes.length) {
tbody.innerHTML = '<tr><td colspan="9" style="text-align:center; color:white;">No nodes found</td></tr>';
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center; color:white;">No nodes found</td></tr>';
} else {
nodes.forEach(node => {
const row = document.createElement("tr");
const isFav = isFavorite(node.node_id);
const starClass = isFav ? 'favorite-star active' : 'favorite-star';
const starIcon = isFav ? '★' : '☆';
row.innerHTML = `
<td>${node.short_name || "N/A"}</td>
<td><a href="/packet_list/${node.node_id}">${node.long_name || "N/A"}</a></td>
@@ -255,7 +358,9 @@ document.addEventListener("DOMContentLoaded", async function() {
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
<td>${node.channel || "N/A"}</td>
<td>${node.last_update ? new Date(node.last_update).toLocaleString() : "N/A"}</td>
<td style="text-align:center;"><span class="${starClass}" data-node-id="${node.node_id}">${starIcon}</span></td>
`;
tbody.appendChild(row);
});
}
@@ -270,6 +375,9 @@ document.addEventListener("DOMContentLoaded", async function() {
searchBox.value = "";
sortColumn = "short_name";
sortAsc = true;
showOnlyFavorites = false;
favoritesBtn.textContent = "⭐ Show Favorites";
favoritesBtn.classList.remove("active");
renderTable(allNodes);
updateSortIcons();
}

View File

@@ -93,88 +93,87 @@
{% block body %}
<div class="main-container">
<h2 class="main-header">Mesh Statistics - Summary (all available in Database)</h2>
<h2 class="main-header" data-translate-lang="mesh_stats_summary">Mesh Statistics - Summary (all available in Database)</h2>
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
<div class="summary-card" style="flex:1;">
<p>Total Nodes</p>
<p data-translate-lang="total_nodes">Total Nodes</p>
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p>Total Packets</p>
<p data-translate-lang="total_packets">Total Packets</p>
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
</div>
<div class="summary-card" style="flex:1;">
<p>Total Packets Seen</p>
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
</div>
</div>
<!-- Daily Charts -->
<div class="card-section">
<p class="section-header">Packets per Day - All Ports (Last 14 Days)</p>
<div id="total_daily_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_all">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_all">Export CSV</button>
<div id="chart_daily_all" class="chart"></div>
</div>
<!-- Daily Charts -->
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_all">Packets per Day - All Ports (Last 14 Days)</p>
<div id="total_daily_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_all" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_all" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_all" class="chart"></div>
</div>
<!-- Packet Types Pie Chart with Channel Selector (moved here) -->
<div class="card-section">
<p class="section-header">Packet Types - Last 24 Hours</p>
<select id="channelSelect">
<option value="">All Channels</option>
</select>
<button class="expand-btn" data-chart="chart_packet_types">Expand Chart</button>
<button class="export-btn" data-chart="chart_packet_types">Export CSV</button>
<div id="chart_packet_types" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
<div id="total_daily_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_portnum_1">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_portnum_1">Export CSV</button>
<div id="chart_daily_portnum_1" class="chart"></div>
</div>
<!-- Packet Types Pie Chart with Channel Selector -->
<div class="card-section">
<p class="section-header" data-translate-lang="packet_types_last_24h">Packet Types - Last 24 Hours</p>
<select id="channelSelect">
<option value="" data-translate-lang="all_channels">All Channels</option>
</select>
<button class="expand-btn" data-chart="chart_packet_types" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_packet_types" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_packet_types" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header" data-translate-lang="packets_per_day_text">Packets per Day - Text Messages (Port 1, Last 14 Days)</p>
<div id="total_daily_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_daily_portnum_1" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_daily_portnum_1" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_daily_portnum_1" class="chart"></div>
</div>
<!-- Hourly Charts -->
<div class="card-section">
<p class="section-header">Packets per Hour - All Ports</p>
<p class="section-header" data-translate-lang="packets_per_hour_all">Packets per Hour - All Ports</p>
<div id="total_hourly_all" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_hourly_all">Expand Chart</button>
<button class="export-btn" data-chart="chart_hourly_all">Export CSV</button>
<button class="expand-btn" data-chart="chart_hourly_all" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_hourly_all" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_hourly_all" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Packets per Hour - Text Messages (Port 1)</p>
<p class="section-header" data-translate-lang="packets_per_hour_text">Packets per Hour - Text Messages (Port 1)</p>
<div id="total_portnum_1" class="total-count">Total: 0</div>
<button class="expand-btn" data-chart="chart_portnum_1">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_1">Export CSV</button>
<button class="expand-btn" data-chart="chart_portnum_1" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_portnum_1" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_portnum_1" class="chart"></div>
</div>
<!-- Node breakdown charts -->
<div class="card-section">
<p class="section-header">Hardware Breakdown</p>
<button class="expand-btn" data-chart="chart_hw_model">Expand Chart</button>
<button class="export-btn" data-chart="chart_hw_model">Export CSV</button>
<p class="section-header" data-translate-lang="hardware_breakdown">Hardware Breakdown</p>
<button class="expand-btn" data-chart="chart_hw_model" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_hw_model" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_hw_model" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Role Breakdown</p>
<button class="expand-btn" data-chart="chart_role">Expand Chart</button>
<button class="export-btn" data-chart="chart_role">Export CSV</button>
<p class="section-header" data-translate-lang="role_breakdown">Role Breakdown</p>
<button class="expand-btn" data-chart="chart_role" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_role" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_role" class="chart"></div>
</div>
<div class="card-section">
<p class="section-header">Channel Breakdown</p>
<button class="expand-btn" data-chart="chart_channel">Expand Chart</button>
<button class="export-btn" data-chart="chart_channel">Export CSV</button>
<p class="section-header" data-translate-lang="channel_breakdown">Channel Breakdown</p>
<button class="expand-btn" data-chart="chart_channel" data-translate-lang="expand_chart">Expand Chart</button>
<button class="export-btn" data-chart="chart_channel" data-translate-lang="export_csv">Export CSV</button>
<div id="chart_channel" class="chart"></div>
</div>
</div>
@@ -215,87 +214,17 @@ async function fetchStats(period_type,length,portnum=null,channel=null){
}catch{return [];}
}
async function fetchNodes(){
try{
const res=await fetch("/api/nodes");
const json=await res.json();
return json.nodes||[];
}catch{return [];}
}
async function fetchNodes(){ try{ const res=await fetch("/api/nodes"); const json=await res.json(); return json.nodes||[];}catch{return [];} }
async function fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} }
async function fetchChannels(){
try{
const res = await fetch("/api/channels");
const json = await res.json();
return json.channels || [];
}catch{return [];}
}
function processCountField(nodes,field){
const counts={};
nodes.forEach(n=>{
const key=n[field]||"Unknown";
counts[key]=(counts[key]||0)+1;
});
return Object.entries(counts).map(([name,value])=>({name,value}));
}
function updateTotalCount(domId,data){
const el=document.getElementById(domId);
if(!el||!data.length) return;
const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0);
el.textContent=`Total: ${total.toLocaleString()}`;
}
function prepareTopN(data,n=20){
data.sort((a,b)=>b.value-a.value);
let top=data.slice(0,n);
if(data.length>n){
const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0);
top.push({name:"Other", value:otherValue});
}
return top;
}
function processCountField(nodes,field){ const counts={}; nodes.forEach(n=>{ const key=n[field]||"Unknown"; counts[key]=(counts[key]||0)+1; }); return Object.entries(counts).map(([name,value])=>({name,value})); }
function updateTotalCount(domId,data){ const el=document.getElementById(domId); if(!el||!data.length) return; const total=data.reduce((acc,d)=>acc+(d.count??d.packet_count??0),0); el.textContent=`Total: ${total.toLocaleString()}`; }
function prepareTopN(data,n=20){ data.sort((a,b)=>b.value-a.value); let top=data.slice(0,n); if(data.length>n){ const otherValue=data.slice(n).reduce((sum,item)=>sum+item.value,0); top.push({name:"Other", value:otherValue}); } return top; }
// --- Chart Rendering ---
function renderChart(domId,data,type,color,isHourly){
const el=document.getElementById(domId);
if(!el) return;
const chart=echarts.init(el);
const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():'');
const counts=data.map(d=>d.count??d.packet_count??0);
const option={
backgroundColor:'#272b2f',
tooltip:{trigger:'axis'},
grid:{left:'6%', right:'6%', bottom:'18%'},
xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}},
yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}},
series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]
};
chart.setOption(option);
return chart;
}
function renderChart(domId,data,type,color){ const el=document.getElementById(domId); if(!el) return; const chart=echarts.init(el); const periods=data.map(d=>(d.period??d.period===0)?d.period.toString():''); const counts=data.map(d=>d.count??d.packet_count??0); chart.setOption({backgroundColor:'#272b2f', tooltip:{trigger:'axis'}, grid:{left:'6%', right:'6%', bottom:'18%'}, xAxis:{type:'category', data:periods, axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{rotate:45,color:'#ccc'}}, yAxis:{type:'value', axisLine:{lineStyle:{color:'#aaa'}}, axisLabel:{color:'#ccc'}}, series:[{data:counts,type:type,smooth:type==='line',itemStyle:{color:color}, areaStyle:type==='line'?{}:undefined}]}); return chart; }
function renderPieChart(elId,data,name){
const el=document.getElementById(elId);
if(!el) return;
const chart=echarts.init(el);
const top20=prepareTopN(data,20);
const option={
backgroundColor:"#272b2f",
tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`},
series:[{
name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"],
avoidLabelOverlap:true,
itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2},
label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10},
labelLine:{show:true,length:10,length2:6},
data:top20
}]
};
chart.setOption(option);
return chart;
}
function renderPieChart(elId,data,name){ const el=document.getElementById(elId); if(!el) return; const chart=echarts.init(el); const top20=prepareTopN(data,20); chart.setOption({backgroundColor:"#272b2f", tooltip:{trigger:"item", formatter: params=>`${params.name}: ${Math.round(params.percent)}% (${params.value})`}, series:[{name:name, type:"pie", radius:["30%","70%"], center:["50%","50%"], avoidLabelOverlap:true, itemStyle:{borderRadius:6,borderColor:"#272b2f",borderWidth:2}, label:{show:true,formatter:"{b}\n{d}%", color:"#ccc", fontSize:10}, labelLine:{show:true,length:10,length2:6}, data:top20}]}); return chart; }
// --- Packet Type Pie Chart ---
async function fetchPacketTypeBreakdown(channel=null) {
@@ -305,10 +234,8 @@ async function fetchPacketTypeBreakdown(channel=null) {
const total = (data || []).reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
return {portnum: pn, count: total};
});
const allData = await fetchStats('hour',24,null,channel);
const totalAll = allData.reduce((sum,d)=>sum+(d.count??d.packet_count??0),0);
const results = await Promise.all(requests);
const trackedTotal = results.reduce((sum,d)=>sum+d.count,0);
const other = Math.max(totalAll - trackedTotal,0);
@@ -323,62 +250,41 @@ let chartHwModel, chartRole, chartChannel;
let chartPacketTypes;
async function init(){
// Populate channels
const channels = await fetchChannels();
const select = document.getElementById("channelSelect");
channels.forEach(ch=>{
const opt = document.createElement("option");
opt.value = ch;
opt.textContent = ch;
select.appendChild(opt);
});
channels.forEach(ch=>{ const opt = document.createElement("option"); opt.value = ch; opt.textContent = ch; select.appendChild(opt); });
// Daily
const dailyAllData=await fetchStats('day',14);
updateTotalCount('total_daily_all',dailyAllData);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a',false);
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
const dailyPort1Data=await fetchStats('day',14,1);
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722',false);
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
// Hourly
const hourlyAllData=await fetchStats('hour',24);
updateTotalCount('total_hourly_all',hourlyAllData);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6',true);
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
const portnums=[1,3,4,67,70,71];
const colors=['#ff5722','#2196f3','#9c27b0','#ffeb3b','#795548','#4caf50'];
const domIds=['chart_portnum_1','chart_portnum_3','chart_portnum_4','chart_portnum_67','chart_portnum_70','chart_portnum_71'];
const totalIds=['total_portnum_1','total_portnum_3','total_portnum_4','total_portnum_67','total_portnum_70','total_portnum_71'];
const allData=await Promise.all(portnums.map(pn=>fetchStats('hour',24,pn)));
for(let i=0;i<portnums.length;i++){
updateTotalCount(totalIds[i],allData[i]);
window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i],true);
}
for(let i=0;i<portnums.length;i++){ updateTotalCount(totalIds[i],allData[i]); window['chartPortnum'+portnums[i]]=renderChart(domIds[i],allData[i],'bar',colors[i]); }
// Node Breakdown
const nodes=await fetchNodes();
chartHwModel=renderPieChart("chart_hw_model",processCountField(nodes,"hw_model"),"Hardware");
chartRole=renderPieChart("chart_role",processCountField(nodes,"role"),"Role");
chartChannel=renderPieChart("chart_channel",processCountField(nodes,"channel"),"Channel");
// Packet Type Pie Chart
const packetTypesData = await fetchPacketTypeBreakdown();
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
value: d.count
}));
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
}
// --- Resize ---
window.addEventListener('resize',()=>{
[chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71,
chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize());
});
window.addEventListener('resize',()=>{ [chartHourlyAll,chartPortnum1,chartPortnum3,chartPortnum4,chartPortnum67,chartPortnum70,chartPortnum71, chartDailyAll,chartDailyPortnum1,chartHwModel,chartRole,chartChannel,chartPacketTypes].forEach(c=>c?.resize()); });
// --- Modal ---
const modal=document.getElementById("chartModal");
const modalChartEl=document.getElementById("modalChart");
let modalChart=null;
@@ -400,7 +306,6 @@ document.getElementById("closeModal").addEventListener("click",()=>{
modalChart=null;
});
// --- CSV Export ---
function downloadCSV(filename,rows){
const csvContent=rows.map(r=>r.map(v=>`"${v}"`).join(",")).join("\n");
const blob=new Blob([csvContent],{type:"text/csv;charset=utf-8;"});
@@ -437,18 +342,34 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
});
});
// --- Channel filter for Packet Types ---
document.getElementById("channelSelect").addEventListener("change", async (e)=>{
const channel = e.target.value;
const packetTypesData = await fetchPacketTypeBreakdown(channel);
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({
name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`),
value: d.count
}));
const formatted = packetTypesData.filter(d=>d.count>0).map(d=>({ name: d.portnum==="other" ? "Other" : (PORTNUM_LABELS[d.portnum]||`Port ${d.portnum}`), value: d.count }));
chartPacketTypes?.dispose();
chartPacketTypes = renderPieChart("chart_packet_types",formatted,"Packet Types (Last 24h)");
});
init();
// --- Translation Loader ---
async function loadTranslations() {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
try {
const res = await fetch(`/api/lang?lang=${langCode}&section=stats`);
window.statsTranslations = await res.json();
} catch(err){
console.error("Stats translation load failed:", err);
window.statsTranslations = {};
}
}
function applyTranslations() {
const t = window.statsTranslations || {};
document.querySelectorAll("[data-translate-lang]").forEach(el=>{
const key = el.getAttribute("data-translate-lang");
if(t[key]) el.textContent = t[key];
});
}
loadTranslations().then(applyTranslations);
</script>
{% endblock %}

View File

@@ -81,15 +81,22 @@ select {
{% endblock %}
{% block body %}
<h1>Top Traffic Nodes (last 24 hours)</h1>
<h1 data-translate-lang="top_traffic_nodes">Top Traffic Nodes (last 24 hours)</h1>
<!-- Channel Filter Dropdown -->
<select id="channelFilter"></select>
<div id="stats">
<p>This chart shows a bell curve (normal distribution) based on the total <strong>"Times Seen"</strong> values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.</p>
<p>This "Times Seen" value is the closest that we can get to Mesh utilization by node.</p>
<p><strong>Mean:</strong> <span id="mean"></span> - <strong>Standard Deviation:</strong> <span id="stdDev"></span></p>
<p data-translate-lang="chart_description_1">
This chart shows a bell curve (normal distribution) based on the total <strong>"Times Seen"</strong> values for all nodes. It helps visualize how frequently nodes are heard, relative to the average.
</p>
<p data-translate-lang="chart_description_2">
This "Times Seen" value is the closest that we can get to Mesh utilization by node.
</p>
<p>
<strong data-translate-lang="mean_label">Mean:</strong> <span id="mean"></span> -
<strong data-translate-lang="stddev_label">Standard Deviation:</strong> <span id="stdDev"></span>
</p>
</div>
<!-- Chart -->
@@ -97,23 +104,23 @@ select {
<!-- Table -->
{% if nodes %}
<div class="container">
<div class="container">
<table id="trafficTable">
<thead>
<tr>
<th onclick="sortTable(0)">Long Name</th>
<th onclick="sortTable(1)">Short Name</th>
<th onclick="sortTable(2)">Channel</th>
<th onclick="sortTable(3)">Packets Sent</th>
<th onclick="sortTable(4)">Times Seen</th>
<th onclick="sortTable(5)">Seen % of Mean</th>
</tr>
</thead>
<tbody></tbody>
<thead>
<tr>
<th data-translate-lang="long_name" onclick="sortTable(0)">Long Name</th>
<th data-translate-lang="short_name" onclick="sortTable(1)">Short Name</th>
<th data-translate-lang="channel" onclick="sortTable(2)">Channel</th>
<th data-translate-lang="packets_sent" onclick="sortTable(3)">Packets Sent</th>
<th data-translate-lang="times_seen" onclick="sortTable(4)">Times Seen</th>
<th data-translate-lang="seen_percent" onclick="sortTable(5)">Seen % of Mean</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
{% else %}
<p style="text-align: center;">No top traffic nodes available.</p>
<p style="text-align: center;" data-translate-lang="no_nodes">No top traffic nodes available.</p>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/echarts@5.3.2/dist/echarts.min.js"></script>
@@ -121,31 +128,45 @@ select {
const nodes = {{ nodes | tojson }};
let filteredNodes = [];
// Chart & Stats
// --- Language support ---
async function loadTopTranslations() {
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
try {
const res = await fetch(`/api/lang?lang=${langCode}&section=top`);
window.topTranslations = await res.json();
} catch(err) {
console.error("Top page translation load failed:", err);
window.topTranslations = {};
}
}
function applyTopTranslations() {
const t = window.topTranslations || {};
document.querySelectorAll("[data-translate-lang]").forEach(el=>{
const key = el.getAttribute("data-translate-lang");
if(t[key]) el.textContent = t[key];
});
}
// --- Chart & Table code ---
const chart = echarts.init(document.getElementById('bellCurveChart'));
const meanEl = document.getElementById('mean');
const stdEl = document.getElementById('stdDev');
// Populate Channel Dropdown (without "All"), default to "LongFast"
// Populate channel dropdown
const channelSet = new Set();
nodes.forEach(n => channelSet.add(n.channel));
const dropdown = document.getElementById('channelFilter');
const sortedChannels = [...channelSet].sort();
sortedChannels.forEach(channel => {
[...channelSet].sort().forEach(channel => {
const option = document.createElement('option');
option.value = channel;
option.textContent = channel;
if (channel === "LongFast") {
option.selected = true;
}
if (channel === "LongFast") option.selected = true;
dropdown.appendChild(option);
});
// Default to LongFast filter on load
// Filter default
filteredNodes = nodes.filter(n => n.channel === "LongFast");
// Filter change handler
dropdown.addEventListener('change', () => {
const val = dropdown.value;
filteredNodes = nodes.filter(n => n.channel === val);
@@ -153,18 +174,16 @@ dropdown.addEventListener('change', () => {
updateStatsAndChart();
});
// Normal distribution function
// Normal distribution
function normalDistribution(x, mean, stdDev) {
return (1 / (stdDev * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * Math.pow((x - mean) / stdDev, 2));
return (1 / (stdDev * Math.sqrt(2 * Math.PI))) * Math.exp(-0.5 * Math.pow((x - mean) / stdDev, 2));
}
// Update table based on filteredNodes
// Update table
function updateTable() {
const tbody = document.querySelector('#trafficTable tbody');
tbody.innerHTML = "";
const mean = filteredNodes.reduce((sum, n) => sum + n.total_times_seen, 0) / (filteredNodes.length || 1);
for (const node of filteredNodes) {
const percent = mean > 0 ? ((node.total_times_seen / mean) * 100).toFixed(1) + "%" : "0%";
const row = `<tr>
@@ -182,72 +201,88 @@ function updateTable() {
// Update chart & stats
function updateStatsAndChart() {
const timesSeen = filteredNodes.map(n => n.total_times_seen);
const mean = timesSeen.reduce((sum, v) => sum + v, 0) / (timesSeen.length || 1);
const stdDev = Math.sqrt(timesSeen.reduce((sum, v) => sum + Math.pow(v - mean, 2), 0) / (timesSeen.length || 1));
const mean = timesSeen.reduce((sum,v)=>sum+v,0)/(timesSeen.length||1);
const stdDev = Math.sqrt(timesSeen.reduce((sum,v)=>sum+Math.pow(v-mean,2),0)/(timesSeen.length||1));
meanEl.textContent = mean.toFixed(2);
stdEl.textContent = stdDev.toFixed(2);
const min = Math.min(...timesSeen);
const max = Math.max(...timesSeen);
const step = (max - min) / 100;
const xData = [], yData = [];
const xData=[], yData=[];
for(let x=min;x<=max;x+=step){ xData.push(x); yData.push(normalDistribution(x,mean,stdDev)); }
for (let x = min; x <= max; x += step) {
xData.push(x);
yData.push(normalDistribution(x, mean, stdDev));
}
const option = {
animation: false,
tooltip: { trigger: 'axis' },
xAxis: {
name: 'Total Times Seen',
type: 'value',
min, max
},
yAxis: {
name: 'Probability Density',
type: 'value',
},
series: [{
data: xData.map((x, i) => [x, yData[i]]),
type: 'line',
smooth: true,
color: 'blue',
lineStyle: { width: 3 }
}]
};
chart.setOption(option);
chart.setOption({
animation:false,
tooltip:{ trigger:'axis' },
xAxis:{ name:'Total Times Seen', type:'value', min, max },
yAxis:{ name:'Probability Density', type:'value' },
series:[{ data:xData.map((x,i)=>[x,yData[i]]), type:'line', smooth:true, color:'blue', lineStyle:{ width:3 }}]
});
chart.resize();
}
// Sorting
// Sort table
function sortTable(n) {
const table = document.getElementById("trafficTable");
const rows = Array.from(table.rows).slice(1);
const header = table.rows[0].cells[n];
const isNumeric = !isNaN(rows[0].cells[n].innerText.replace('%', ''));
let sortedRows = rows.sort((a, b) => {
const valA = isNumeric ? parseFloat(a.cells[n].innerText.replace('%', '')) : a.cells[n].innerText.toLowerCase();
const valB = isNumeric ? parseFloat(b.cells[n].innerText.replace('%', '')) : b.cells[n].innerText.toLowerCase();
const isNumeric = !isNaN(rows[0].cells[n].innerText.replace('%',''));
let sortedRows = rows.sort((a,b)=>{
const valA = isNumeric ? parseFloat(a.cells[n].innerText.replace('%','')) : a.cells[n].innerText.toLowerCase();
const valB = isNumeric ? parseFloat(b.cells[n].cells[n].innerText.replace('%','')) : b.cells[n].innerText.toLowerCase();
return valA > valB ? 1 : -1;
});
if (header.getAttribute('data-sort-direction') === 'asc') {
sortedRows.reverse();
header.setAttribute('data-sort-direction', 'desc');
} else {
header.setAttribute('data-sort-direction', 'asc');
}
if(header.getAttribute('data-sort-direction')==='asc'){ sortedRows.reverse(); header.setAttribute('data-sort-direction','desc'); }
else header.setAttribute('data-sort-direction','asc');
const tbody = table.tBodies[0];
sortedRows.forEach(row => tbody.appendChild(row));
sortedRows.forEach(row=>tbody.appendChild(row));
}
// Initialize
updateTable();
updateStatsAndChart();
window.addEventListener('resize', () => chart.resize());
(async ()=>{
await loadTopTranslations();
applyTopTranslations();
updateTable();
updateStatsAndChart();
window.addEventListener('resize',()=>chart.resize());
})();
</script>
{% if timing_data %}
<!-- Performance Metrics Summary -->
<div style="background-color: #1a1d21; border: 1px solid #444; border-radius: 8px; padding: 15px; margin: 20px auto; max-width: 800px; color: #fff;">
<h3 style="margin-top: 0; color: #4CAF50;">⚡ Performance Metrics</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px;">
<div>
<strong>Database Query:</strong><br>
<span style="color: #FFD700; font-size: 1.2em;">{{ timing_data.db_query_ms }}ms</span>
</div>
<div>
<strong>Data Processing:</strong><br>
<span style="color: #FFD700; font-size: 1.2em;">{{ timing_data.processing_ms }}ms</span>
</div>
<div>
<strong>Total Time:</strong><br>
<span style="color: #FFD700; font-size: 1.2em;">{{ timing_data.total_ms }}ms</span>
</div>
<div>
<strong>Nodes Processed:</strong><br>
<span style="color: #4CAF50; font-size: 1.2em;">{{ timing_data.node_count }}</span>
</div>
<div>
<strong>Total Packets:</strong><br>
<span style="color: #4CAF50; font-size: 1.2em;">{{ "{:,}".format(timing_data.total_packets) }}</span>
</div>
<div>
<strong>Times Seen:</strong><br>
<span style="color: #4CAF50; font-size: 1.2em;">{{ "{:,}".format(timing_data.total_seen) }}</span>
</div>
</div>
<p style="margin-bottom: 0; margin-top: 10px; font-size: 0.9em; color: #888;">
📊 Use these metrics to measure performance before and after database index changes
</p>
</div>
{% endif %}
{% endblock %}

File diff suppressed because it is too large Load Diff

121
mvrun.py
View File

@@ -1,9 +1,66 @@
import argparse
import threading
import logging
import os
import signal
import subprocess
import sys
import threading
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(filename)s:%(lineno)d [pid:%(process)d] %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
# Global list to track running processes
running_processes = []
pid_files = []
def cleanup_pid_file(pid_file):
"""Remove a PID file if it exists"""
if os.path.exists(pid_file):
try:
os.remove(pid_file)
logger.info(f"Removed PID file {pid_file}")
except Exception as e:
logger.error(f"Error removing PID file {pid_file}: {e}")
def signal_handler(sig, frame):
"""Handle Ctrl-C gracefully"""
logger.info("Received interrupt signal (Ctrl-C), shutting down gracefully...")
# Terminate all running processes
for process in running_processes:
if process and process.poll() is None: # Process is still running
try:
logger.info(f"Terminating process PID {process.pid}")
process.terminate()
# Give it a moment to terminate gracefully
try:
process.wait(timeout=5)
logger.info(f"Process PID {process.pid} terminated successfully")
except subprocess.TimeoutExpired:
logger.warning(f"Process PID {process.pid} did not terminate, forcing kill")
process.kill()
process.wait()
except Exception as e:
logger.error(f"Error terminating process PID {process.pid}: {e}")
# Clean up PID files
for pid_file in pid_files:
cleanup_pid_file(pid_file)
logger.info("Shutdown complete")
sys.exit(0)
# Run python in subprocess
def run_script(script_name, *args):
def run_script(script_name, pid_file, *args):
process = None
try:
# Path to the Python interpreter inside the virtual environment
python_executable = './env/bin/python'
@@ -11,31 +68,73 @@ def run_script(script_name, *args):
# Combine the script name and arguments
command = [python_executable, script_name] + list(args)
# Run the subprocess and report errors
subprocess.run(command, check=True)
# Run the subprocess (output goes directly to console for real-time viewing)
process = subprocess.Popen(command)
# Track the process globally
running_processes.append(process)
# Write PID to file
with open(pid_file, 'w') as f:
f.write(str(process.pid))
logger.info(f"Started {script_name} with PID {process.pid}, written to {pid_file}")
# Wait for the process to complete
process.wait()
except Exception as e:
print(f"Error running {script_name}: {e}")
logger.error(f"Error running {script_name}: {e}")
finally:
# Clean up PID file when process exits
cleanup_pid_file(pid_file)
# Parse runtime argument (--config) and start subprocess threads
def main():
parser = argparse.ArgumentParser(description="Helper script to run the datbase and web frontend in separate threads.")
# Register signal handler for Ctrl-C
signal.signal(signal.SIGINT, signal_handler)
signal.signal(signal.SIGTERM, signal_handler)
parser = argparse.ArgumentParser(
description="Helper script to run the database and web frontend in separate threads."
)
# Add --config runtime argument
parser.add_argument('--config', help="Path to the configuration file.", default='config.ini')
args = parser.parse_args()
# PID file paths
db_pid_file = 'meshview-db.pid'
web_pid_file = 'meshview-web.pid'
# Track PID files globally for cleanup
pid_files.append(db_pid_file)
pid_files.append(web_pid_file)
# Database Thread
dbthrd = threading.Thread(target=run_script, args=('startdb.py', '--config', args.config))
dbthrd = threading.Thread(
target=run_script, args=('startdb.py', db_pid_file, '--config', args.config)
)
# Web server thread
webthrd = threading.Thread(target=run_script, args=('main.py', '--config', args.config))
webthrd = threading.Thread(
target=run_script, args=('main.py', web_pid_file, '--config', args.config)
)
# Start Meshview subprocess threads
logger.info(f"Starting Meshview with config: {args.config}")
logger.info("Starting database thread...")
dbthrd.start()
logger.info("Starting web server thread...")
webthrd.start()
dbthrd.join()
webthrd.join()
try:
dbthrd.join()
webthrd.join()
except KeyboardInterrupt:
# This shouldn't be reached due to signal handler, but just in case
signal_handler(signal.SIGINT, None)
if __name__ == '__main__':
main()
main()

13
pyproject.toml Normal file
View File

@@ -0,0 +1,13 @@
[tool.ruff]
# Linting
target-version = "py313"
line-length = 100
extend-exclude = ["build", "dist", ".venv"]
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"] # pick your rulesets
ignore = ["E501"] # example; let formatter handle line length
[tool.ruff.format]
quote-style = "preserve"
indent-style = "space"

View File

@@ -22,6 +22,9 @@ acme_challenge =
# The domain name of your site.
domain =
# Select language
language = en
# Site title to show in the browser title bar and headers.
title = Bay Area Mesh
@@ -94,4 +97,15 @@ days_to_keep = 14
hour = 2
minute = 00
# Run VACUUM after cleanup
vacuum = False
vacuum = False
# -------------------------
# Logging Configuration
# -------------------------
[logging]
# Enable or disable HTTP access logs from the web server
# When disabled, request logs like "GET /api/chat" will not appear
# Application logs (errors, startup messages, etc.) are unaffected
# Set to True to enable, False to disable (default: False)
access_log = False

View File

@@ -1,12 +1,11 @@
import asyncio
import json
import datetime
import json
import logging
from sqlalchemy import delete
from meshview import mqtt_reader
from meshview import mqtt_database
from meshview import mqtt_store
from meshview import models
from meshview import models, mqtt_database, mqtt_reader, mqtt_store
from meshview.config import CONFIG
# -------------------------
@@ -20,31 +19,32 @@ formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
file_handler.setFormatter(formatter)
cleanup_logger.addHandler(file_handler)
# -------------------------
# Helper functions
# -------------------------
def get_bool(config, section, key, default=False):
return str(config.get(section, {}).get(key, default)).lower() in ("1", "true", "yes", "on")
def get_int(config, section, key, default=0):
try:
return int(config.get(section, {}).get(key, default))
except ValueError:
return default
# -------------------------
# Shared DB lock
# -------------------------
db_lock = asyncio.Lock()
# -------------------------
# Database cleanup using ORM
# -------------------------
async def daily_cleanup_at(
hour: int = 2,
minute: int = 0,
days_to_keep: int = 14,
vacuum_db: bool = True
hour: int = 2, minute: int = 0, days_to_keep: int = 14, vacuum_db: bool = True
):
while True:
now = datetime.datetime.now()
@@ -56,7 +56,9 @@ async def daily_cleanup_at(
await asyncio.sleep(delay)
# Local-time cutoff as string for SQLite DATETIME comparison
cutoff = (datetime.datetime.now() - datetime.timedelta(days=days_to_keep)).strftime("%Y-%m-%d %H:%M:%S")
cutoff = (datetime.datetime.now() - datetime.timedelta(days=days_to_keep)).strftime(
"%Y-%m-%d %H:%M:%S"
)
cleanup_logger.info(f"Running cleanup for records older than {cutoff}...")
try:
@@ -110,6 +112,7 @@ async def daily_cleanup_at(
except Exception as e:
cleanup_logger.error(f"Error during cleanup: {e}")
# -------------------------
# MQTT loading
# -------------------------
@@ -118,7 +121,7 @@ async def load_database_from_mqtt(
mqtt_port: int,
topics: list,
mqtt_user: str | None = None,
mqtt_passwd: str | None = None
mqtt_passwd: str | None = None,
):
async for topic, env in mqtt_reader.get_topic_envelopes(
mqtt_server, mqtt_port, topics, mqtt_user, mqtt_passwd
@@ -126,6 +129,7 @@ async def load_database_from_mqtt(
async with db_lock: # Block if cleanup is running
await mqtt_store.process_envelope(topic, env)
# -------------------------
# Main function
# -------------------------
@@ -156,12 +160,11 @@ async def main():
)
if cleanup_enabled:
tg.create_task(
daily_cleanup_at(cleanup_hour, cleanup_minute, cleanup_days, vacuum_db)
)
tg.create_task(daily_cleanup_at(cleanup_hour, cleanup_minute, cleanup_days, vacuum_db))
else:
cleanup_logger.info("Daily cleanup is disabled by configuration.")
# -------------------------
# Entry point
# -------------------------