mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Compare commits
49 Commits
v2.0.7
...
revert-73-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f94bc0e39 | ||
|
|
5d687da598 | ||
|
|
a002cde2d7 | ||
|
|
454c8ff6e2 | ||
|
|
021bc54f9d | ||
|
|
155ef89724 | ||
|
|
084647eec1 | ||
|
|
c13a851145 | ||
|
|
114cd980b9 | ||
|
|
c23a650c0d | ||
|
|
318bf83403 | ||
|
|
636ab3e976 | ||
|
|
ea10a656e7 | ||
|
|
bcd007e5e2 | ||
|
|
b35acde821 | ||
|
|
b7752bc315 | ||
|
|
257bf7ffac | ||
|
|
d561d1a8de | ||
|
|
60e7389d83 | ||
|
|
4ac3262544 | ||
|
|
87643e4bd2 | ||
|
|
29174a649c | ||
|
|
712aea5139 | ||
|
|
d6fadd99d0 | ||
|
|
ae0b0944f0 | ||
|
|
d7b830e2f7 | ||
|
|
4a1737ebd4 | ||
|
|
60131007df | ||
|
|
23d66c0d67 | ||
|
|
30ba603f66 | ||
|
|
9811102681 | ||
|
|
7c92b06bec | ||
|
|
adda666a39 | ||
|
|
3e673f30bc | ||
|
|
beefb4c5df | ||
|
|
e1bada8378 | ||
|
|
fbd6fcb123 | ||
|
|
5d267effa5 | ||
|
|
e28d248cf9 | ||
|
|
ab101dd461 | ||
|
|
35212d403e | ||
|
|
3603014fd2 | ||
|
|
e25ff22127 | ||
|
|
aa9922e7fa | ||
|
|
a9b16d6c18 | ||
|
|
b4fda0bb01 | ||
|
|
215817abc7 | ||
|
|
f167e8780d | ||
|
|
2723022dd5 |
39
.github/workflows/lint.yml
vendored
Normal file
39
.github/workflows/lint.yml
vendored
Normal 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
2
.gitignore
vendored
@@ -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
8
.pre-commit-config.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.13.3 # pin the latest you’re 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
203
PERFORMANCE_OPTIMIZATION.md
Normal 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
|
||||
20
README.md
20
README.md
@@ -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
154
add_db_indexes.py
Normal 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
133
contributing.md
Normal 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 don’t have time to contribute code, that’s fine! You can still support Meshview by:
|
||||
> - ⭐ Starring the repo on GitHub
|
||||
> - Talking about Meshview on social media
|
||||
> - Referencing Meshview in your own project’s 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 project’s license.
|
||||
|
||||
---
|
||||
|
||||
### Reporting Bugs
|
||||
|
||||
Before submitting a bug report:
|
||||
- Make sure you’re 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 doesn’t already exist.
|
||||
- Search for prior suggestions.
|
||||
- Check that it fits Meshview’s scope (mesh packet analysis, visualization, telemetry, etc.).
|
||||
|
||||
When submitting:
|
||||
- Use a **clear and descriptive title**.
|
||||
- Describe the current behavior and what you’d 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 you’d 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), we’d love to invite you as a maintainer.
|
||||
|
||||
Start by contributing regularly, engaging in issues/PRs, and helping others.
|
||||
|
||||
---
|
||||
|
||||
✨ That’s it! Thanks again for being part of Meshview. Every contribution matters.
|
||||
8
main.py
8
main.py
@@ -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())
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
110
meshview/lang/en.json
Normal 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
112
meshview/lang/es.json
Normal 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!"
|
||||
}
|
||||
}
|
||||
@@ -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"),)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
225
meshview/static/kiosk.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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" %} - <a href="/chat">Conversations</a>{% endif %}
|
||||
{% if site.get("everything") == "True" %} - <a href="/firehose">See <strong>everything</strong></a>{% endif %}
|
||||
{% if site.get("graphs") == "True" %} - <a href="/nodegraph">Mesh Graphs</a>{% endif %}
|
||||
{% if site.get("net") == "True" %} - <a href="/net">Weekly Net</a>{% endif %}
|
||||
{% if site.get("map") == "True" %} - <a href="/map">Live Map</a>{% endif %}
|
||||
{% if site.get("stats") == "True" %} - <a href="/stats">Stats</a>{% endif %}
|
||||
{% if site.get("top") == "True" %} - <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" %} - <a href="/chat" id="nav-conversations" data-translate-lang="conversations">Conversations</a>{% endif %}
|
||||
{% if site.get("everything") == "True" %} - <a href="/firehose" id="nav-everything" data-translate-lang="everything">See Everything</a>{% endif %}
|
||||
{% if site.get("graphs") == "True" %} - <a href="/nodegraph" id="nav-graph" data-translate-lang="graph">Mesh Graphs</a>{% endif %}
|
||||
{% if site.get("net") == "True" %} - <a href="/net" id="nav-net" data-translate-lang="net">Weekly Net</a>{% endif %}
|
||||
{% if site.get("map") == "True" %} - <a href="/map" id="nav-map" data-translate-lang="map">Live Map</a>{% endif %}
|
||||
{% if site.get("stats") == "True" %} - <a href="/stats" id="nav-stats" data-translate-lang="stats">Stats</a>{% endif %}
|
||||
{% if site.get("top") == "True" %} - <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}§ion=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>
|
||||
|
||||
@@ -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 we’ve 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}§ion=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 %}
|
||||
|
||||
@@ -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}§ion=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: '© <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 %}
|
||||
@@ -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}§ion=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 %}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
z-index: 10;1
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}§ion=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 %}
|
||||
|
||||
@@ -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}§ion=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 %}
|
||||
|
||||
576
meshview/web.py
576
meshview/web.py
File diff suppressed because it is too large
Load Diff
121
mvrun.py
121
mvrun.py
@@ -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
13
pyproject.toml
Normal 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"
|
||||
@@ -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
|
||||
|
||||
31
startdb.py
31
startdb.py
@@ -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
|
||||
# -------------------------
|
||||
|
||||
Reference in New Issue
Block a user