mirror of
https://github.com/pablorevilla-meshtastic/meshview.git
synced 2026-03-04 23:27:46 +01:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9912f6b181 | ||
|
|
cb4cc281c6 | ||
|
|
571559114d | ||
|
|
df26df07f1 | ||
|
|
ffc7340bc9 | ||
|
|
1d58aaba83 | ||
|
|
b2bb9345fe | ||
|
|
9686622b56 | ||
|
|
f7644a9573 | ||
|
|
e48e9464d7 | ||
|
|
b72bc5d52b | ||
|
|
1220f0bcbd | ||
|
|
539410d5bb | ||
|
|
383b576d18 | ||
|
|
64a55a3ef3 | ||
|
|
9408201e57 | ||
|
|
f75d6bf749 | ||
|
|
924d223866 | ||
|
|
e9dcca1f19 | ||
|
|
00cc2abd23 | ||
|
|
b76477167d | ||
|
|
b41b249a6d | ||
|
|
71fcda2dd6 | ||
|
|
c4453fbb31 | ||
|
|
79fa3f66a8 | ||
|
|
0ce64ac975 | ||
|
|
350aa9e4a3 | ||
|
|
e5bbf972c7 | ||
|
|
4326e12e88 | ||
|
|
00aa3216ff | ||
|
|
3d6c01f020 | ||
|
|
d3bf0ede67 | ||
|
|
2b02166d82 | ||
|
|
2fd36b4b11 | ||
|
|
8aa1c59873 | ||
|
|
cd036b8004 | ||
|
|
989da239fb | ||
|
|
31626494d3 | ||
|
|
960a7ef075 | ||
|
|
60c4d22d2d | ||
|
|
13a094be00 | ||
|
|
7744cedd8c | ||
|
|
ad42c1aeaf | ||
|
|
41f7bf42a3 | ||
|
|
0543aeb650 | ||
|
|
679071cc14 | ||
|
|
198afcc7d8 | ||
|
|
191a01a03c | ||
|
|
fd653f8234 | ||
|
|
2149fed8c5 | ||
|
|
5609d18284 | ||
|
|
705b0b79fc | ||
|
|
32ad8e3a9c | ||
|
|
e77428661c | ||
|
|
e68cdf8cc1 |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
# This keeps Docker from including hostOS virtual environment folders
|
||||
env/
|
||||
.venv/
|
||||
|
||||
# Database files and backups
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
backups/
|
||||
*.db.gz
|
||||
52
.github/workflows/container.yml
vendored
Normal file
52
.github/workflows/container.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: Build container
|
||||
|
||||
on:
|
||||
push:
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
# list of Docker images to use as base name for tags
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
# generate Docker tags based on the following events/attributes
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=match,pattern=v\d.\d.\d,value=latest
|
||||
- name: Login to GitHub Container Registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Containerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
platforms: linux/amd64,linux/arm64
|
||||
# optional cache (speeds up rebuilds)
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
36
.gitignore
vendored
36
.gitignore
vendored
@@ -1,11 +1,47 @@
|
||||
env/*
|
||||
__pycache__/*
|
||||
meshview/__pycache__/*
|
||||
alembic/__pycache__/*
|
||||
meshtastic/protobuf/*
|
||||
|
||||
# Database files
|
||||
packets.db
|
||||
packets*.db
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
*.db.gz
|
||||
|
||||
# Process files
|
||||
meshview-db.pid
|
||||
meshview-web.pid
|
||||
|
||||
# Config and logs
|
||||
/table_details.py
|
||||
config.ini
|
||||
*.log
|
||||
|
||||
# Screenshots
|
||||
screenshots/*
|
||||
|
||||
# Python
|
||||
python/nanopb
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
204
AGENTS.md
Normal file
204
AGENTS.md
Normal file
@@ -0,0 +1,204 @@
|
||||
# AI Agent Guidelines for Meshview
|
||||
|
||||
This document provides context and guidelines for AI coding assistants working on the Meshview project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Meshview is a real-time monitoring and diagnostic tool for Meshtastic mesh networks. It provides web-based visualization and analysis of network activity, including:
|
||||
|
||||
- Real-time packet monitoring from MQTT streams
|
||||
- Interactive map visualization of node locations
|
||||
- Network topology graphs showing connectivity
|
||||
- Message traffic analysis and conversation tracking
|
||||
- Node statistics and telemetry data
|
||||
- Packet inspection and traceroute analysis
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
1. **MQTT Reader** (`meshview/mqtt_reader.py`) - Subscribes to MQTT topics and receives mesh packets
|
||||
2. **Database Manager** (`meshview/database.py`, `startdb.py`) - Handles database initialization and migrations
|
||||
3. **MQTT Store** (`meshview/mqtt_store.py`) - Processes and stores packets in the database
|
||||
4. **Web Server** (`meshview/web.py`, `main.py`) - Serves the web interface and API endpoints
|
||||
5. **API Layer** (`meshview/web_api/api.py`) - REST API endpoints for data access
|
||||
6. **Models** (`meshview/models.py`) - SQLAlchemy database models
|
||||
7. **Decode Payload** (`meshview/decode_payload.py`) - Protobuf message decoding
|
||||
|
||||
### Technology Stack
|
||||
|
||||
- **Python 3.13+** - Main language
|
||||
- **aiohttp** - Async web framework
|
||||
- **aiomqtt** - Async MQTT client
|
||||
- **SQLAlchemy (async)** - ORM with async support
|
||||
- **Alembic** - Database migrations
|
||||
- **Jinja2** - Template engine
|
||||
- **Protobuf** - Message serialization (Meshtastic protocol)
|
||||
- **SQLite/PostgreSQL** - Database backends (SQLite default, PostgreSQL via asyncpg)
|
||||
|
||||
### Key Patterns
|
||||
|
||||
- **Async/Await** - All I/O operations are asynchronous
|
||||
- **Database Migrations** - Use Alembic for schema changes (see `docs/Database-Changes-With-Alembic.md`)
|
||||
- **Configuration** - INI file-based config (`config.ini`, see `sample.config.ini`)
|
||||
- **Modular API** - API routes separated into `meshview/web_api/` module
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
meshview/
|
||||
├── alembic/ # Database migration scripts
|
||||
├── docs/ # Technical documentation
|
||||
├── meshview/ # Main application package
|
||||
│ ├── static/ # Static web assets (HTML, JS, CSS)
|
||||
│ ├── templates/ # Jinja2 HTML templates
|
||||
│ ├── web_api/ # API route handlers
|
||||
│ └── *.py # Core modules
|
||||
├── main.py # Web server entry point
|
||||
├── startdb.py # Database manager entry point
|
||||
├── mvrun.py # Combined runner (starts both services)
|
||||
├── config.ini # Runtime configuration
|
||||
└── requirements.txt # Python dependencies
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Setup
|
||||
|
||||
1. Use Python 3.13+ virtual environment
|
||||
|
||||
### Running
|
||||
|
||||
- **Database**: `./env/bin/python startdb.py`
|
||||
- **Web Server**: `./env/bin/python main.py`
|
||||
- **Both**: `./env/bin/python mvrun.py`
|
||||
|
||||
|
||||
## Code Style
|
||||
|
||||
- **Line length**: 100 characters (see `pyproject.toml`)
|
||||
- **Linting**: Ruff (configured in `pyproject.toml`)
|
||||
- **Formatting**: Ruff formatter
|
||||
- **Type hints**: Preferred but not strictly required
|
||||
- **Async**: Use `async def` and `await` for I/O operations
|
||||
|
||||
## Important Files
|
||||
|
||||
### Configuration
|
||||
- `config.ini` - Runtime configuration (server, MQTT, database, cleanup)
|
||||
- `sample.config.ini` - Template configuration file
|
||||
- `alembic.ini` - Alembic migration configuration
|
||||
|
||||
### Database
|
||||
- `meshview/models.py` - SQLAlchemy models (Packet, Node, Traceroute, etc.)
|
||||
- `meshview/database.py` - Database initialization and session management
|
||||
- `alembic/versions/` - Migration scripts
|
||||
|
||||
### Core Logic
|
||||
- `meshview/mqtt_reader.py` - MQTT subscription and message reception
|
||||
- `meshview/mqtt_store.py` - Packet processing and storage
|
||||
- `meshview/decode_payload.py` - Protobuf decoding
|
||||
- `meshview/web.py` - Web server routes and handlers
|
||||
- `meshview/web_api/api.py` - REST API endpoints
|
||||
|
||||
### Templates
|
||||
- `meshview/templates/` - Jinja2 HTML templates
|
||||
- `meshview/static/` - Static files (HTML pages, JS, CSS)
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. Add route handler in `meshview/web_api/api.py`
|
||||
2. Register route in `meshview/web.py` (if needed)
|
||||
3. Update `docs/API_Documentation.md` if public API
|
||||
|
||||
### Database Schema Changes
|
||||
|
||||
1. Modify models in `meshview/models.py`
|
||||
2. Create migration: `alembic revision --autogenerate -m "description"`
|
||||
3. Review generated migration in `alembic/versions/`
|
||||
4. Test migration: `alembic upgrade head`
|
||||
5. **Never** modify existing migration files after they've been applied
|
||||
|
||||
### Adding a New Web Page
|
||||
|
||||
1. Create template in `meshview/templates/`
|
||||
2. Add route in `meshview/web.py`
|
||||
3. Add navigation link if needed (check existing templates for pattern)
|
||||
4. Add static assets if needed in `meshview/static/`
|
||||
|
||||
### Processing New Packet Types
|
||||
|
||||
1. Check `meshview/decode_payload.py` for existing decoders
|
||||
2. Add decoder function if new type
|
||||
3. Update `meshview/mqtt_store.py` to handle new packet type
|
||||
4. Update database models if new data needs storage
|
||||
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Meshtastic Protocol
|
||||
- Uses Protobuf for message serialization
|
||||
- Packets contain various message types (text, position, telemetry, etc.)
|
||||
- MQTT topics follow pattern: `msh/{region}/{subregion}/#`
|
||||
|
||||
### Database Schema
|
||||
- **packet** - Raw packet data
|
||||
- **node** - Mesh node information
|
||||
- **traceroute** - Network path information
|
||||
- **packet_seen** - Packet observation records
|
||||
|
||||
### Real-time Updates
|
||||
- Web pages use Server-Sent Events (SSE) for live updates
|
||||
- Map and firehose pages auto-refresh based on config intervals
|
||||
- API endpoints return JSON for programmatic access
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Always use async/await** for database and network operations
|
||||
2. **Use Alembic** for all database schema changes
|
||||
3. **Follow existing patterns** - check similar code before adding new features
|
||||
4. **Update documentation** - keep `docs/` and README current
|
||||
5. **Test migrations** - verify migrations work both up and down
|
||||
6. **Handle errors gracefully** - log errors, don't crash on bad packets
|
||||
7. **Respect configuration** - use `config.ini` values, don't hardcode
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Don't modify applied migrations** - create new ones instead
|
||||
- **Don't block the event loop** - use async I/O, not sync
|
||||
- **Don't forget timezone handling** - timestamps are stored in UTC
|
||||
- **Don't hardcode paths** - use configuration values
|
||||
- **Don't ignore MQTT reconnection** - handle connection failures gracefully
|
||||
|
||||
## Resources
|
||||
|
||||
- **Main README**: `README.md` - Installation and basic usage
|
||||
- **Docker Guide**: `README-Docker.md` - Container deployment
|
||||
- **API Docs**: `docs/API_Documentation.md` - API endpoint reference
|
||||
- **Migration Guide**: `docs/Database-Changes-With-Alembic.md` - Database workflow
|
||||
- **Contributing**: `CONTRIBUTING.md` - Contribution guidelines
|
||||
|
||||
## Version Information
|
||||
|
||||
- **Current Version**: 3.0.0 (November 2025)
|
||||
- **Python Requirement**: 3.13+
|
||||
- **Key Features**: Alembic migrations, automated backups, Docker support, traceroute return paths
|
||||
|
||||
|
||||
## Rules for robots
|
||||
- Always run ruff check and ruff format after making changes (only on python changes)
|
||||
|
||||
|
||||
---
|
||||
|
||||
When working on this project, prioritize:
|
||||
1. Maintaining async patterns
|
||||
2. Following existing code structure
|
||||
3. Using proper database migrations
|
||||
4. Keeping documentation updated
|
||||
5. Testing changes thoroughly
|
||||
|
||||
|
||||
|
||||
80
Containerfile
Normal file
80
Containerfile
Normal file
@@ -0,0 +1,80 @@
|
||||
# Build Image
|
||||
# Uses python:3.13-slim because no native dependencies are needed for meshview itself
|
||||
# (everything is available as a wheel)
|
||||
|
||||
FROM docker.io/python:3.13-slim AS meshview-build
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl patch && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Add a non-root user/group
|
||||
ARG APP_USER=app
|
||||
RUN useradd -m -u 10001 -s /bin/bash ${APP_USER}
|
||||
|
||||
# Install uv and put it on PATH system-wide
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
|
||||
&& install -m 0755 /root/.local/bin/uv /usr/local/bin/uv
|
||||
|
||||
WORKDIR /app
|
||||
RUN chown -R ${APP_USER}:${APP_USER} /app
|
||||
|
||||
# Copy deps first for caching
|
||||
COPY --chown=${APP_USER}:${APP_USER} pyproject.toml uv.lock* requirements*.txt ./
|
||||
|
||||
# Optional: wheels-only to avoid slow source builds
|
||||
ENV UV_NO_BUILD=1
|
||||
RUN uv venv /opt/venv
|
||||
# RUN uv sync --frozen
|
||||
ENV VIRTUAL_ENV=/opt/venv
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
RUN uv pip install --no-cache-dir --upgrade pip \
|
||||
&& if [ -f requirements.txt ]; then uv pip install --only-binary=:all: -r requirements.txt; fi
|
||||
|
||||
# Copy app code
|
||||
COPY --chown=${APP_USER}:${APP_USER} . .
|
||||
|
||||
# Patch config
|
||||
RUN patch sample.config.ini < container/config.patch
|
||||
|
||||
# Clean
|
||||
RUN rm -rf /app/.git* && \
|
||||
rm -rf /app/.pre-commit-config.yaml && \
|
||||
rm -rf /app/*.md && \
|
||||
rm -rf /app/COPYING && \
|
||||
rm -rf /app/Containerfile && \
|
||||
rm -rf /app/Dockerfile && \
|
||||
rm -rf /app/container && \
|
||||
rm -rf /app/docker && \
|
||||
rm -rf /app/docs && \
|
||||
rm -rf /app/pyproject.toml && \
|
||||
rm -rf /app/requirements.txt && \
|
||||
rm -rf /app/screenshots
|
||||
|
||||
# Prepare /app and /opt to copy
|
||||
RUN mkdir -p /meshview && \
|
||||
mv /app /opt /meshview
|
||||
|
||||
# Use a clean container for install
|
||||
FROM docker.io/python:3.13-slim
|
||||
ARG APP_USER=app
|
||||
COPY --from=meshview-build /meshview /
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends graphviz && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
useradd -m -u 10001 -s /bin/bash ${APP_USER} && \
|
||||
mkdir -p /etc/meshview /var/lib/meshview /var/log/meshview && \
|
||||
mv /app/sample.config.ini /etc/meshview/config.ini && \
|
||||
chown -R ${APP_USER}:${APP_USER} /var/lib/meshview /var/log/meshview
|
||||
|
||||
# Drop privileges
|
||||
USER ${APP_USER}
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENTRYPOINT [ "/opt/venv/bin/python", "mvrun.py"]
|
||||
CMD ["--pid_dir", "/tmp", "--py_exec", "/opt/venv/bin/python", "--config", "/etc/meshview/config.ini" ]
|
||||
|
||||
EXPOSE 8081
|
||||
VOLUME [ "/etc/meshview", "/var/lib/meshview", "/var/log/meshview" ]
|
||||
|
||||
1
Dockerfile
Symbolic link
1
Dockerfile
Symbolic link
@@ -0,0 +1 @@
|
||||
Containerfile
|
||||
@@ -1,203 +0,0 @@
|
||||
# /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
|
||||
243
README-Docker.md
Normal file
243
README-Docker.md
Normal file
@@ -0,0 +1,243 @@
|
||||
# Running MeshView with Docker
|
||||
|
||||
MeshView container images are built automatically and published to GitHub Container Registry.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Pull and run the latest image:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
-p 8081:8081 \
|
||||
-v ./config:/etc/meshview \
|
||||
-v ./data:/var/lib/meshview \
|
||||
-v ./logs:/var/log/meshview \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
Access the web interface at: http://localhost:8081
|
||||
|
||||
## Volume Mounts
|
||||
|
||||
The container uses three volumes for persistent data:
|
||||
|
||||
| Volume | Purpose | Required |
|
||||
|--------|---------|----------|
|
||||
| `/etc/meshview` | Configuration files | Yes |
|
||||
| `/var/lib/meshview` | Database storage | Recommended |
|
||||
| `/var/log/meshview` | Log files | Optional |
|
||||
|
||||
### Configuration Volume
|
||||
|
||||
Mount a directory containing your `config.ini` file:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/config:/etc/meshview
|
||||
```
|
||||
|
||||
If no config is provided, the container will use the default `sample.config.ini`.
|
||||
|
||||
### Database Volume
|
||||
|
||||
Mount a directory to persist the SQLite database:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/data:/var/lib/meshview
|
||||
```
|
||||
|
||||
**Important:** Without this mount, your database will be lost when the container stops.
|
||||
|
||||
### Logs Volume
|
||||
|
||||
Mount a directory to access logs from the host:
|
||||
|
||||
```bash
|
||||
-v /path/to/your/logs:/var/log/meshview
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
Create a directory structure and run:
|
||||
|
||||
```bash
|
||||
# Create directories
|
||||
mkdir -p meshview/{config,data,logs,backups}
|
||||
|
||||
# Copy sample config (first time only)
|
||||
docker run --rm ghcr.io/pablorevilla-meshtastic/meshview:latest \
|
||||
cat /etc/meshview/config.ini > meshview/config/config.ini
|
||||
|
||||
# Edit config.ini with your MQTT settings
|
||||
nano meshview/config/config.ini
|
||||
|
||||
# Run the container
|
||||
docker run -d \
|
||||
--name meshview \
|
||||
--restart unless-stopped \
|
||||
-p 8081:8081 \
|
||||
-v $(pwd)/meshview/config:/etc/meshview \
|
||||
-v $(pwd)/meshview/data:/var/lib/meshview \
|
||||
-v $(pwd)/meshview/logs:/var/log/meshview \
|
||||
ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
## Docker Compose
|
||||
|
||||
Create a `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
meshview:
|
||||
image: ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
container_name: meshview
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./config:/etc/meshview
|
||||
- ./data:/var/lib/meshview
|
||||
- ./logs:/var/log/meshview
|
||||
- ./backups:/var/lib/meshview/backups # For database backups
|
||||
environment:
|
||||
- TZ=America/Los_Angeles # Set your timezone
|
||||
```
|
||||
|
||||
Run with:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Minimum Configuration
|
||||
|
||||
Edit your `config.ini` to configure MQTT connection:
|
||||
|
||||
```ini
|
||||
[mqtt]
|
||||
server = mqtt.meshtastic.org
|
||||
topics = ["msh/US/#"]
|
||||
port = 1883
|
||||
username =
|
||||
password =
|
||||
|
||||
[database]
|
||||
connection_string = sqlite+aiosqlite:///var/lib/meshview/packets.db
|
||||
```
|
||||
|
||||
### Database Backups
|
||||
|
||||
To enable automatic daily backups inside the container:
|
||||
|
||||
```ini
|
||||
[cleanup]
|
||||
backup_enabled = True
|
||||
backup_dir = /var/lib/meshview/backups
|
||||
backup_hour = 2
|
||||
backup_minute = 00
|
||||
```
|
||||
|
||||
Then mount the backups directory:
|
||||
|
||||
```bash
|
||||
-v $(pwd)/meshview/backups:/var/lib/meshview/backups
|
||||
```
|
||||
|
||||
## Available Tags
|
||||
|
||||
| Tag | Description |
|
||||
|-----|-------------|
|
||||
| `latest` | Latest build from the main branch |
|
||||
| `dev-v3` | Development branch |
|
||||
| `v1.2.3` | Specific version tags |
|
||||
|
||||
## Updating
|
||||
|
||||
Pull the latest image and restart:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
docker restart meshview
|
||||
```
|
||||
|
||||
Or with docker-compose:
|
||||
|
||||
```bash
|
||||
docker-compose pull
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
View container logs:
|
||||
|
||||
```bash
|
||||
docker logs meshview
|
||||
|
||||
# Follow logs
|
||||
docker logs -f meshview
|
||||
|
||||
# Last 100 lines
|
||||
docker logs --tail 100 meshview
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Container won't start
|
||||
|
||||
Check logs:
|
||||
```bash
|
||||
docker logs meshview
|
||||
```
|
||||
|
||||
### Database permission issues
|
||||
|
||||
Ensure the data directory is writable:
|
||||
```bash
|
||||
chmod -R 755 meshview/data
|
||||
```
|
||||
|
||||
### Can't connect to MQTT
|
||||
|
||||
1. Check your MQTT configuration in `config.ini`
|
||||
2. Verify network connectivity from the container:
|
||||
```bash
|
||||
docker exec meshview ping mqtt.meshtastic.org
|
||||
```
|
||||
|
||||
### Port already in use
|
||||
|
||||
Change the host port (left side):
|
||||
```bash
|
||||
-p 8082:8081
|
||||
```
|
||||
|
||||
Then access at: http://localhost:8082
|
||||
|
||||
## Building Your Own Image
|
||||
|
||||
If you want to build from source:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pablorevilla-meshtastic/meshview.git
|
||||
cd meshview
|
||||
docker build -f Containerfile -t meshview:local .
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
- The container runs as a non-root user (`app`, UID 10001)
|
||||
- No privileged access required
|
||||
- Only port 8081 is exposed
|
||||
- All data stored in mounted volumes
|
||||
|
||||
## Support
|
||||
|
||||
- GitHub Issues: https://github.com/pablorevilla-meshtastic/meshview/issues
|
||||
- Documentation: https://github.com/pablorevilla-meshtastic/meshview
|
||||
138
README.md
138
README.md
@@ -4,6 +4,44 @@
|
||||
|
||||
The project serves as a real-time monitoring and diagnostic tool for the Meshtastic mesh network. It provides detailed insights into network activity, including message traffic, node positions, and telemetry data.
|
||||
|
||||
### Version 3.0.1 — December 2025
|
||||
|
||||
#### 🌐 Multi-Language Support (i18n)
|
||||
- New `/api/lang` endpoint for serving translations
|
||||
- Section-based translation loading (e.g., `?section=firehose`)
|
||||
- Default language controlled via config file language section
|
||||
- JSON-based translation files for easy expansion
|
||||
- Core pages updated to support `data-translate-lang` attributes
|
||||
|
||||
### 🛠 Improvements
|
||||
- Updated UI elements across multiple templates for localization readiness
|
||||
- General cleanup to support future language additions
|
||||
|
||||
### Version 3.0.0 update - November 2025
|
||||
|
||||
**Major Infrastructure Improvements:**
|
||||
|
||||
* **Database Migrations**: Alembic integration for safe schema upgrades and database versioning
|
||||
* **Automated Backups**: Independent database backup system with gzip compression (separate from cleanup)
|
||||
* **Development Tools**: Quick setup script (`setup-dev.sh`) with pre-commit hooks for code quality
|
||||
* **Docker Support**: Pre-built containers now available on GitHub Container Registry with automatic builds - ogarcia
|
||||
|
||||
**New Features:**
|
||||
|
||||
* **Traceroute Return Path**: Log and display return path data for traceroute packets - jschrempp
|
||||
* **Microsecond Timestamps**: Added `import_time_us` columns for higher precision time tracking
|
||||
|
||||
**Technical Improvements:**
|
||||
|
||||
* Migration from manual SQL to Alembic-managed schema
|
||||
* Container images use `uv` for faster dependency installation
|
||||
* Python 3.13 support with slim Debian-based images
|
||||
* Documentation collection in `docs/` directory
|
||||
* API routes moved to separate modules for better organization
|
||||
* /version and /health endpoints added for monitoring
|
||||
|
||||
See [README-Docker.md](README-Docker.md) for container deployment and [docs/](docs/) for technical documentation.
|
||||
|
||||
### Version 2.0.7 update - September 2025
|
||||
* New database maintenance capability to automatically keep a specific number of days of data.
|
||||
* Added configuration for update intervals for both the Live Map and the Firehose pages.
|
||||
@@ -14,6 +52,7 @@ The project serves as a real-time monitoring and diagnostic tool for the Meshtas
|
||||
* New API /api/edges (See API documentation)
|
||||
* Adds edges to the map (click to see traceroute and neighbours)
|
||||
|
||||
|
||||
### Version 2.0.4 update - August 2025
|
||||
* New statistic page with more data.
|
||||
* New API /api/stats (See API documentation).
|
||||
@@ -48,32 +87,69 @@ Samples of currently running instances:
|
||||
- https://map.wpamesh.net (Western Pennsylvania)
|
||||
- https://meshview.chicagolandmesh.org (Chicago)
|
||||
- https://meshview.mt.gt (Canadaverse)
|
||||
- https://canadaverse.org (Canadaverse)
|
||||
- https://meshview.meshtastic.es (Spain)
|
||||
- https://view.mtnme.sh (North Georgia / East Tennessee)
|
||||
- https://meshview.lsinfra.de (Hessen - Germany)
|
||||
- https://map.nswmesh.au (Sydney - Australia)
|
||||
- https://meshview.pvmesh.org (Pioneer Valley, Massachusetts)
|
||||
- https://meshview.louisianamesh.org (Louisiana)
|
||||
- https://www.swlamesh.com/map (Southwest Louisiana)
|
||||
- https://meshview.meshcolombia.co/ (Colombia)
|
||||
- https://meshview-salzburg.jmt.gr/ (Salzburg / Austria)
|
||||
---
|
||||
|
||||
|
||||
### Updating from 2.x to 3.x
|
||||
We are adding the use of Alembic. If using GitHub
|
||||
Update your codebase by running the pull command
|
||||
```bash
|
||||
cd meshview
|
||||
git pull origin master
|
||||
```
|
||||
Install Alembic in your environment
|
||||
```bash
|
||||
./env/bin/pip install alembic
|
||||
```
|
||||
Start your scripts or services. This process will update your database with the latest schema.
|
||||
|
||||
## Installing
|
||||
|
||||
Requires **`python3.11`** or above.
|
||||
### Using Docker (Recommended)
|
||||
|
||||
The easiest way to run MeshView is using Docker. Pre-built images are available from GitHub Container Registry.
|
||||
|
||||
See **[README-Docker.md](README-Docker.md)** for complete Docker installation and usage instructions.
|
||||
|
||||
### Manual Installation
|
||||
|
||||
Requires **`python3.13`** or above.
|
||||
|
||||
Clone the repo from GitHub:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/pablorevilla-meshtastic/meshview.git
|
||||
```
|
||||
|
||||
```bash
|
||||
cd meshview
|
||||
```
|
||||
|
||||
#### Quick Setup (Recommended)
|
||||
|
||||
Run the development setup script:
|
||||
|
||||
```bash
|
||||
./setup-dev.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
- Create Python virtual environment
|
||||
- Install all requirements
|
||||
- Install development tools (pre-commit, pytest)
|
||||
- Set up pre-commit hooks for code formatting
|
||||
- Create config.ini from sample
|
||||
|
||||
#### Manual Setup
|
||||
|
||||
Create a Python virtual environment:
|
||||
|
||||
from the meshview directory...
|
||||
```bash
|
||||
python3 -m venv env
|
||||
```
|
||||
@@ -136,6 +212,9 @@ acme_challenge =
|
||||
# The domain name of your site.
|
||||
domain =
|
||||
|
||||
# Select language (this represents the name of the json file in the /lang directory)
|
||||
language = es
|
||||
|
||||
# Site title to show in the browser title bar and headers.
|
||||
title = Bay Area Mesh
|
||||
|
||||
@@ -221,6 +300,8 @@ vacuum = False
|
||||
# Application logs (errors, startup messages, etc.) are unaffected
|
||||
# Set to True to enable, False to disable (default: False)
|
||||
access_log = False
|
||||
# Database cleanup logfile location
|
||||
db_cleanup_logfile = dbcleanup.log
|
||||
```
|
||||
|
||||
---
|
||||
@@ -254,12 +335,29 @@ Open in your browser: http://localhost:8081/
|
||||
## Running Meshview with `mvrun.py`
|
||||
|
||||
- `mvrun.py` starts both `startdb.py` and `main.py` in separate threads and merges the output.
|
||||
- It accepts the `--config` argument like the others.
|
||||
- It accepts several command-line arguments for flexible deployment.
|
||||
|
||||
```bash
|
||||
./env/bin/python mvrun.py
|
||||
```
|
||||
|
||||
**Command-line options:**
|
||||
- `--config CONFIG` - Path to the configuration file (default: `config.ini`)
|
||||
- `--pid_dir PID_DIR` - Directory for PID files (default: `.`)
|
||||
- `--py_exec PY_EXEC` - Path to the Python executable (default: `./env/bin/python`)
|
||||
|
||||
**Examples:**
|
||||
```bash
|
||||
# Use a specific config file
|
||||
./env/bin/python mvrun.py --config /etc/meshview/config.ini
|
||||
|
||||
# Store PID files in a specific directory
|
||||
./env/bin/python mvrun.py --pid_dir /var/run/meshview
|
||||
|
||||
# Use a different Python executable
|
||||
./env/bin/python mvrun.py --py_exec /usr/bin/python3
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setting Up Systemd Services (Ubuntu)
|
||||
@@ -365,6 +463,15 @@ hour = 2
|
||||
minute = 00
|
||||
# Run VACUUM after cleanup
|
||||
vacuum = False
|
||||
|
||||
# -------------------------
|
||||
# Logging Configuration
|
||||
# -------------------------
|
||||
[logging]
|
||||
# Enable or disable HTTP access logs from the web server
|
||||
access_log = False
|
||||
# Database cleanup logfile location
|
||||
db_cleanup_logfile = dbcleanup.log
|
||||
```
|
||||
Once changes are done you need to restart the script for changes to load.
|
||||
|
||||
@@ -413,3 +520,20 @@ 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.
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
MeshView includes a test suite using pytest. For detailed testing documentation, see [README-testing.md](README-testing.md).
|
||||
|
||||
Quick start:
|
||||
```bash
|
||||
./env/bin/pytest tests/test_api_simple.py -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
For more detailed technical documentation including database migrations, architecture details, and advanced topics, see the [docs/](docs/) directory.
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
#!/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())
|
||||
120
alembic.ini
Normal file
120
alembic.ini
Normal file
@@ -0,0 +1,120 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts
|
||||
# Use forward slashes (/) also on windows to provide an os agnostic path
|
||||
script_location = alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory.
|
||||
prepend_sys_path = .
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the python>=3.9 or backports.zoneinfo library and tzdata library.
|
||||
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to alembic/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "version_path_separator" below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
|
||||
|
||||
# version path separator; As mentioned above, this is the character used to split
|
||||
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
|
||||
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
|
||||
# Valid values for version_path_separator are:
|
||||
#
|
||||
# version_path_separator = :
|
||||
# version_path_separator = ;
|
||||
# version_path_separator = space
|
||||
# version_path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
version_path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# sqlalchemy.url will be set programmatically from meshview config
|
||||
# sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = %(here)s/.venv/bin/ruff
|
||||
# ruff.options = --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = INFO
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(asctime)s %(filename)s:%(lineno)d [pid:%(process)d] %(levelname)s - %(message)s
|
||||
datefmt = %Y-%m-%d %H:%M:%S
|
||||
1
alembic/README
Normal file
1
alembic/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
102
alembic/env.py
Normal file
102
alembic/env.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import asyncio
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from alembic import context
|
||||
|
||||
# Import models metadata for autogenerate support
|
||||
from meshview.models import Base
|
||||
|
||||
# this is the Alembic Config object, which provides
|
||||
# access to the values within the .ini file in use.
|
||||
config = context.config
|
||||
|
||||
# Interpret the config file for Python logging.
|
||||
# This line sets up loggers basically.
|
||||
# Use disable_existing_loggers=False to preserve app logging configuration
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name, disable_existing_loggers=False)
|
||||
|
||||
# Add your model's MetaData object here for 'autogenerate' support
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""Run migrations in 'offline' mode.
|
||||
|
||||
This configures the context with just a URL
|
||||
and not an Engine, though an Engine is acceptable
|
||||
here as well. By skipping the Engine creation
|
||||
we don't even need a DBAPI to be available.
|
||||
|
||||
Calls to context.execute() here emit the given string to the
|
||||
script output.
|
||||
|
||||
"""
|
||||
url = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url,
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
"""Run migrations with the given connection."""
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
"""Run migrations in async mode."""
|
||||
# Get configuration section
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
|
||||
# If sqlalchemy.url is not set in alembic.ini, try to get it from meshview config
|
||||
if "sqlalchemy.url" not in configuration:
|
||||
try:
|
||||
from meshview.config import CONFIG
|
||||
|
||||
configuration["sqlalchemy.url"] = CONFIG["database"]["connection_string"]
|
||||
except Exception:
|
||||
# Fallback to a default for initial migration creation
|
||||
configuration["sqlalchemy.url"] = "sqlite+aiosqlite:///packets.db"
|
||||
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""Run migrations in 'online' mode with async support."""
|
||||
try:
|
||||
# Event loop is already running, schedule and run the coroutine
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
pool.submit(lambda: asyncio.run(run_async_migrations())).result()
|
||||
except RuntimeError:
|
||||
# No event loop running, create one
|
||||
asyncio.run(run_async_migrations())
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
26
alembic/script.py.mako
Normal file
26
alembic/script.py.mako
Normal file
@@ -0,0 +1,26 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
45
alembic/versions/1717fa5c6545_add_example_table.py
Normal file
45
alembic/versions/1717fa5c6545_add_example_table.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""Add example table
|
||||
|
||||
Revision ID: 1717fa5c6545
|
||||
Revises: c88468b7ab0b
|
||||
Create Date: 2025-10-26 20:59:04.347066
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1717fa5c6545'
|
||||
down_revision: str | None = 'add_time_us_cols'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create example table with sample columns."""
|
||||
op.create_table(
|
||||
'example',
|
||||
sa.Column('id', sa.Integer(), nullable=False, primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('value', sa.Float(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column(
|
||||
'created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')
|
||||
),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
|
||||
# Create an index on the name column for faster lookups
|
||||
op.create_index('idx_example_name', 'example', ['name'])
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove example table."""
|
||||
op.drop_index('idx_example_name', table_name='example')
|
||||
op.drop_table('example')
|
||||
35
alembic/versions/2b5a61bb2b75_auto_generated_migration.py
Normal file
35
alembic/versions/2b5a61bb2b75_auto_generated_migration.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Add first_seen_us and last_seen_us to node table
|
||||
|
||||
Revision ID: 2b5a61bb2b75
|
||||
Revises: ac311b3782a1
|
||||
Create Date: 2025-11-05 15:19:13.446724
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '2b5a61bb2b75'
|
||||
down_revision: str | None = 'ac311b3782a1'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add microsecond epoch timestamp columns for first and last seen times
|
||||
op.add_column('node', sa.Column('first_seen_us', sa.BigInteger(), nullable=True))
|
||||
op.add_column('node', sa.Column('last_seen_us', sa.BigInteger(), nullable=True))
|
||||
op.create_index('idx_node_first_seen_us', 'node', ['first_seen_us'], unique=False)
|
||||
op.create_index('idx_node_last_seen_us', 'node', ['last_seen_us'], unique=False)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove the microsecond epoch timestamp columns and their indexes
|
||||
op.drop_index('idx_node_last_seen_us', table_name='node')
|
||||
op.drop_index('idx_node_first_seen_us', table_name='node')
|
||||
op.drop_column('node', 'last_seen_us')
|
||||
op.drop_column('node', 'first_seen_us')
|
||||
@@ -0,0 +1,31 @@
|
||||
"""add route_return to traceroute
|
||||
|
||||
Revision ID: ac311b3782a1
|
||||
Revises: 1717fa5c6545
|
||||
Create Date: 2025-11-04 20:28:33.174137
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'ac311b3782a1'
|
||||
down_revision: str | None = '1717fa5c6545'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Add route_return column to traceroute table
|
||||
with op.batch_alter_table('traceroute', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('route_return', sa.LargeBinary(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove route_return column from traceroute table
|
||||
with op.batch_alter_table('traceroute', schema=None) as batch_op:
|
||||
batch_op.drop_column('route_return')
|
||||
74
alembic/versions/add_import_time_us_columns.py
Normal file
74
alembic/versions/add_import_time_us_columns.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""add import_time_us columns
|
||||
|
||||
Revision ID: add_time_us_cols
|
||||
Revises: c88468b7ab0b
|
||||
Create Date: 2025-11-03 14:10:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'add_time_us_cols'
|
||||
down_revision: str | None = 'c88468b7ab0b'
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Check if columns already exist, add them if they don't
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# Add import_time_us to packet table
|
||||
packet_columns = [col['name'] for col in inspector.get_columns('packet')]
|
||||
if 'import_time_us' not in packet_columns:
|
||||
with op.batch_alter_table('packet', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('import_time_us', sa.BigInteger(), nullable=True))
|
||||
op.create_index(
|
||||
'idx_packet_import_time_us', 'packet', [sa.text('import_time_us DESC')], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
'idx_packet_from_node_time_us',
|
||||
'packet',
|
||||
['from_node_id', sa.text('import_time_us DESC')],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# Add import_time_us to packet_seen table
|
||||
packet_seen_columns = [col['name'] for col in inspector.get_columns('packet_seen')]
|
||||
if 'import_time_us' not in packet_seen_columns:
|
||||
with op.batch_alter_table('packet_seen', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('import_time_us', sa.BigInteger(), nullable=True))
|
||||
op.create_index(
|
||||
'idx_packet_seen_import_time_us', 'packet_seen', ['import_time_us'], unique=False
|
||||
)
|
||||
|
||||
# Add import_time_us to traceroute table
|
||||
traceroute_columns = [col['name'] for col in inspector.get_columns('traceroute')]
|
||||
if 'import_time_us' not in traceroute_columns:
|
||||
with op.batch_alter_table('traceroute', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('import_time_us', sa.BigInteger(), nullable=True))
|
||||
op.create_index(
|
||||
'idx_traceroute_import_time_us', 'traceroute', ['import_time_us'], unique=False
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Drop indexes and columns
|
||||
op.drop_index('idx_traceroute_import_time_us', table_name='traceroute')
|
||||
with op.batch_alter_table('traceroute', schema=None) as batch_op:
|
||||
batch_op.drop_column('import_time_us')
|
||||
|
||||
op.drop_index('idx_packet_seen_import_time_us', table_name='packet_seen')
|
||||
with op.batch_alter_table('packet_seen', schema=None) as batch_op:
|
||||
batch_op.drop_column('import_time_us')
|
||||
|
||||
op.drop_index('idx_packet_from_node_time_us', table_name='packet')
|
||||
op.drop_index('idx_packet_import_time_us', table_name='packet')
|
||||
with op.batch_alter_table('packet', schema=None) as batch_op:
|
||||
batch_op.drop_column('import_time_us')
|
||||
160
alembic/versions/c88468b7ab0b_initial_migration.py
Normal file
160
alembic/versions/c88468b7ab0b_initial_migration.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: c88468b7ab0b
|
||||
Revises:
|
||||
Create Date: 2025-10-26 20:56:50.285200
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = 'c88468b7ab0b'
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Get connection and inspector to check what exists
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
existing_tables = inspector.get_table_names()
|
||||
|
||||
# Create node table if it doesn't exist
|
||||
if 'node' not in existing_tables:
|
||||
op.create_table(
|
||||
'node',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('node_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('long_name', sa.String(), nullable=True),
|
||||
sa.Column('short_name', sa.String(), nullable=True),
|
||||
sa.Column('hw_model', sa.String(), nullable=True),
|
||||
sa.Column('firmware', sa.String(), nullable=True),
|
||||
sa.Column('role', sa.String(), nullable=True),
|
||||
sa.Column('last_lat', sa.BigInteger(), nullable=True),
|
||||
sa.Column('last_long', sa.BigInteger(), nullable=True),
|
||||
sa.Column('channel', sa.String(), nullable=True),
|
||||
sa.Column('last_update', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('node_id'),
|
||||
)
|
||||
op.create_index('idx_node_node_id', 'node', ['node_id'], unique=False)
|
||||
|
||||
# Create packet table if it doesn't exist
|
||||
if 'packet' not in existing_tables:
|
||||
op.create_table(
|
||||
'packet',
|
||||
sa.Column('id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('portnum', sa.Integer(), nullable=True),
|
||||
sa.Column('from_node_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('to_node_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('payload', sa.LargeBinary(), nullable=True),
|
||||
sa.Column('import_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('import_time_us', sa.BigInteger(), nullable=True),
|
||||
sa.Column('channel', sa.String(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('idx_packet_from_node_id', 'packet', ['from_node_id'], unique=False)
|
||||
op.create_index('idx_packet_to_node_id', 'packet', ['to_node_id'], unique=False)
|
||||
op.create_index(
|
||||
'idx_packet_import_time', 'packet', [sa.text('import_time DESC')], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
'idx_packet_import_time_us', 'packet', [sa.text('import_time_us DESC')], unique=False
|
||||
)
|
||||
op.create_index(
|
||||
'idx_packet_from_node_time',
|
||||
'packet',
|
||||
['from_node_id', sa.text('import_time DESC')],
|
||||
unique=False,
|
||||
)
|
||||
op.create_index(
|
||||
'idx_packet_from_node_time_us',
|
||||
'packet',
|
||||
['from_node_id', sa.text('import_time_us DESC')],
|
||||
unique=False,
|
||||
)
|
||||
|
||||
# Create packet_seen table if it doesn't exist
|
||||
if 'packet_seen' not in existing_tables:
|
||||
op.create_table(
|
||||
'packet_seen',
|
||||
sa.Column('packet_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('node_id', sa.BigInteger(), nullable=False),
|
||||
sa.Column('rx_time', sa.BigInteger(), nullable=False),
|
||||
sa.Column('hop_limit', sa.Integer(), nullable=True),
|
||||
sa.Column('hop_start', sa.Integer(), nullable=True),
|
||||
sa.Column('channel', sa.String(), nullable=True),
|
||||
sa.Column('rx_snr', sa.Float(), nullable=True),
|
||||
sa.Column('rx_rssi', sa.Integer(), nullable=True),
|
||||
sa.Column('topic', sa.String(), nullable=True),
|
||||
sa.Column('import_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('import_time_us', sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['packet_id'],
|
||||
['packet.id'],
|
||||
),
|
||||
sa.PrimaryKeyConstraint('packet_id', 'node_id', 'rx_time'),
|
||||
)
|
||||
op.create_index('idx_packet_seen_node_id', 'packet_seen', ['node_id'], unique=False)
|
||||
op.create_index('idx_packet_seen_packet_id', 'packet_seen', ['packet_id'], unique=False)
|
||||
op.create_index(
|
||||
'idx_packet_seen_import_time_us', 'packet_seen', ['import_time_us'], unique=False
|
||||
)
|
||||
|
||||
# Create traceroute table if it doesn't exist
|
||||
if 'traceroute' not in existing_tables:
|
||||
op.create_table(
|
||||
'traceroute',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('packet_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('gateway_node_id', sa.BigInteger(), nullable=True),
|
||||
sa.Column('done', sa.Boolean(), nullable=True),
|
||||
sa.Column('route', sa.LargeBinary(), nullable=True),
|
||||
sa.Column('import_time', sa.DateTime(), nullable=True),
|
||||
sa.Column('import_time_us', sa.BigInteger(), nullable=True),
|
||||
sa.ForeignKeyConstraint(
|
||||
['packet_id'],
|
||||
['packet.id'],
|
||||
),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
)
|
||||
op.create_index('idx_traceroute_import_time', 'traceroute', ['import_time'], unique=False)
|
||||
op.create_index(
|
||||
'idx_traceroute_import_time_us', 'traceroute', ['import_time_us'], unique=False
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
# Drop traceroute table and indexes
|
||||
op.drop_index('idx_traceroute_import_time_us', table_name='traceroute')
|
||||
op.drop_index('idx_traceroute_import_time', table_name='traceroute')
|
||||
op.drop_table('traceroute')
|
||||
|
||||
# Drop packet_seen table and indexes
|
||||
op.drop_index('idx_packet_seen_import_time_us', table_name='packet_seen')
|
||||
op.drop_index('idx_packet_seen_packet_id', table_name='packet_seen')
|
||||
op.drop_index('idx_packet_seen_node_id', table_name='packet_seen')
|
||||
op.drop_table('packet_seen')
|
||||
|
||||
# Drop packet table and indexes
|
||||
op.drop_index('idx_packet_from_node_time_us', table_name='packet')
|
||||
op.drop_index('idx_packet_from_node_time', table_name='packet')
|
||||
op.drop_index('idx_packet_import_time_us', table_name='packet')
|
||||
op.drop_index('idx_packet_import_time', table_name='packet')
|
||||
op.drop_index('idx_packet_to_node_id', table_name='packet')
|
||||
op.drop_index('idx_packet_from_node_id', table_name='packet')
|
||||
op.drop_table('packet')
|
||||
|
||||
# Drop node table and indexes
|
||||
op.drop_index('idx_node_node_id', table_name='node')
|
||||
op.drop_table('node')
|
||||
# ### end Alembic commands ###
|
||||
57
container/build-container.sh
Executable file
57
container/build-container.sh
Executable file
@@ -0,0 +1,57 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# build-container.sh
|
||||
#
|
||||
# Script to build MeshView container images
|
||||
|
||||
set -e
|
||||
|
||||
# Default values
|
||||
IMAGE_NAME="meshview"
|
||||
TAG="latest"
|
||||
CONTAINERFILE="Containerfile"
|
||||
|
||||
# Parse arguments
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--tag|-t)
|
||||
TAG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--name|-n)
|
||||
IMAGE_NAME="$2"
|
||||
shift 2
|
||||
;;
|
||||
--file|-f)
|
||||
CONTAINERFILE="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -t, --tag TAG Tag for the image (default: latest)"
|
||||
echo " -n, --name NAME Image name (default: meshview)"
|
||||
echo " -f, --file FILE Containerfile path (default: Containerfile)"
|
||||
echo " -h, --help Show this help"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
echo "Use --help for usage information"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Building MeshView container image..."
|
||||
echo " Image: ${IMAGE_NAME}:${TAG}"
|
||||
echo " Containerfile: ${CONTAINERFILE}"
|
||||
echo ""
|
||||
|
||||
# Build the container
|
||||
docker build -f "${CONTAINERFILE}" -t "${IMAGE_NAME}:${TAG}" .
|
||||
|
||||
echo ""
|
||||
echo "Build complete!"
|
||||
echo "Run with: docker run --rm -p 8081:8081 ${IMAGE_NAME}:${TAG}"
|
||||
37
container/config.patch
Normal file
37
container/config.patch
Normal file
@@ -0,0 +1,37 @@
|
||||
diff --git a/sample.config.ini b/sample.config.ini
|
||||
index 0e64980..494685c 100644
|
||||
--- a/sample.config.ini
|
||||
+++ b/sample.config.ini
|
||||
@@ -3,7 +3,7 @@
|
||||
# -------------------------
|
||||
[server]
|
||||
# The address to bind the server to. Use * to listen on all interfaces.
|
||||
-bind = *
|
||||
+bind = 0.0.0.0
|
||||
|
||||
# Port to run the web server on.
|
||||
port = 8081
|
||||
@@ -64,7 +64,7 @@ net_tag = #BayMeshNet
|
||||
# -------------------------
|
||||
[mqtt]
|
||||
# MQTT server hostname or IP.
|
||||
-server = mqtt.bayme.sh
|
||||
+server = mqtt.meshtastic.org
|
||||
|
||||
# Topics to subscribe to (as JSON-like list, but still a string).
|
||||
topics = ["msh/US/bayarea/#", "msh/US/CA/mrymesh/#", "msh/US/CA/sacvalley"]
|
||||
@@ -82,7 +82,7 @@ password = large4cats
|
||||
# -------------------------
|
||||
[database]
|
||||
# SQLAlchemy connection string. This one uses SQLite with asyncio support.
|
||||
-connection_string = sqlite+aiosqlite:///packets.db
|
||||
+connection_string = sqlite+aiosqlite:////var/lib/meshview/packets.db
|
||||
|
||||
|
||||
# -------------------------
|
||||
@@ -110,4 +110,4 @@ vacuum = False
|
||||
# Set to True to enable, False to disable (default: False)
|
||||
access_log = False
|
||||
# Database cleanup logfile
|
||||
-db_cleanup_logfile = dbcleanup.log
|
||||
+db_cleanup_logfile = /var/log/meshview/dbcleanup.log
|
||||
52
create_example_migration.py
Executable file
52
create_example_migration.py
Executable file
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to create a blank migration for manual editing.
|
||||
|
||||
Usage:
|
||||
./env/bin/python create_example_migration.py
|
||||
|
||||
This creates an empty migration file that you can manually edit to add
|
||||
custom migration logic (data migrations, complex schema changes, etc.)
|
||||
|
||||
Unlike create_migration.py which auto-generates from model changes,
|
||||
this creates a blank template for you to fill in.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add current directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from alembic.config import Config
|
||||
|
||||
from alembic import command
|
||||
|
||||
# Create Alembic config
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
|
||||
# Set database URL from meshview config
|
||||
try:
|
||||
from meshview.config import CONFIG
|
||||
|
||||
database_url = CONFIG["database"]["connection_string"]
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
||||
print(f"Using database URL from config: {database_url}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load meshview config: {e}")
|
||||
print("Using default database URL")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", "sqlite+aiosqlite:///packets.db")
|
||||
|
||||
# Generate blank migration
|
||||
try:
|
||||
print("Creating blank migration for manual editing...")
|
||||
command.revision(alembic_cfg, autogenerate=False, message="Manual migration")
|
||||
print("✓ Successfully created blank migration!")
|
||||
print("\nNow edit the generated file in alembic/versions/")
|
||||
print("Add your custom upgrade() and downgrade() logic")
|
||||
except Exception as e:
|
||||
print(f"✗ Error creating migration: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
58
create_migration.py
Executable file
58
create_migration.py
Executable file
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Helper script to create Alembic migrations from SQLAlchemy model changes.
|
||||
|
||||
Usage:
|
||||
./env/bin/python create_migration.py
|
||||
|
||||
This will:
|
||||
1. Load your current models from meshview/models.py
|
||||
2. Compare them to the current database schema
|
||||
3. Auto-generate a migration with the detected changes
|
||||
4. Save the migration to alembic/versions/
|
||||
|
||||
After running this, review the generated migration file before committing!
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add current directory to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from alembic.config import Config
|
||||
|
||||
from alembic import command
|
||||
|
||||
# Create Alembic config
|
||||
alembic_cfg = Config("alembic.ini")
|
||||
|
||||
# Set database URL from meshview config
|
||||
try:
|
||||
from meshview.config import CONFIG
|
||||
|
||||
database_url = CONFIG["database"]["connection_string"]
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
|
||||
print(f"Using database URL from config: {database_url}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not load meshview config: {e}")
|
||||
print("Using default database URL")
|
||||
alembic_cfg.set_main_option("sqlalchemy.url", "sqlite+aiosqlite:///packets.db")
|
||||
|
||||
# Generate migration
|
||||
try:
|
||||
print("\nComparing models to current database schema...")
|
||||
print("Generating migration...\n")
|
||||
command.revision(alembic_cfg, autogenerate=True, message="Auto-generated migration")
|
||||
print("\n✓ Successfully created migration!")
|
||||
print("\nNext steps:")
|
||||
print("1. Review the generated file in alembic/versions/")
|
||||
print("2. Edit the migration message/logic if needed")
|
||||
print("3. Test the migration: ./env/bin/alembic upgrade head")
|
||||
print("4. Commit the migration file to version control")
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error creating migration: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -1,28 +0,0 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
# Set work directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (graphviz required, git for cloning)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends git graphviz && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Clone the repo with submodules
|
||||
RUN git clone --recurse-submodules https://github.com/pablorevilla-meshtastic/meshview.git /app
|
||||
|
||||
# Create virtual environment
|
||||
RUN python -m venv /app/env
|
||||
|
||||
# Upgrade pip and install requirements in venv
|
||||
RUN /app/env/bin/pip install --no-cache-dir --upgrade pip && \
|
||||
/app/env/bin/pip install --no-cache-dir -r /app/requirements.txt
|
||||
|
||||
# Copy sample config
|
||||
RUN cp /app/sample.config.ini /app/config.ini
|
||||
|
||||
# Expose port
|
||||
EXPOSE 8081
|
||||
|
||||
# Run the app via venv
|
||||
CMD ["/app/env/bin/python", "/app/mvrun.py"]
|
||||
@@ -1,44 +1,36 @@
|
||||
# MeshView Docker Container
|
||||
|
||||
This Dockerfile builds a containerized version of the [MeshView](https://github.com/pablorevilla-meshtastic/meshview) application. It uses a lightweight Python environment and sets up the required virtual environment as expected by the application.
|
||||
> **Note:** This directory contains legacy Docker build files.
|
||||
>
|
||||
> **For current Docker usage instructions, please see [README-Docker.md](../README-Docker.md) in the project root.**
|
||||
|
||||
## Image Details
|
||||
## Current Approach
|
||||
|
||||
- **Base Image**: `python:3.12-slim`
|
||||
- **Working Directory**: `/app`
|
||||
- **Python Virtual Environment**: `/app/env`
|
||||
Pre-built container images are automatically built and published to GitHub Container Registry:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/pablorevilla-meshtastic/meshview:latest
|
||||
```
|
||||
|
||||
See **[README-Docker.md](../README-Docker.md)** for:
|
||||
- Quick start instructions
|
||||
- Volume mount configuration
|
||||
- Docker Compose examples
|
||||
- Backup configuration
|
||||
- Troubleshooting
|
||||
|
||||
## Legacy Build (Not Recommended)
|
||||
|
||||
If you need to build your own image for development:
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
docker build -f Containerfile -t meshview:local .
|
||||
```
|
||||
|
||||
The current Containerfile uses:
|
||||
- **Base Image**: `python:3.13-slim` (Debian-based)
|
||||
- **Build tool**: `uv` for fast dependency installation
|
||||
- **User**: Non-root user `app` (UID 10001)
|
||||
- **Exposed Port**: `8081`
|
||||
|
||||
## Build Instructions
|
||||
|
||||
Build the Docker image:
|
||||
|
||||
```bash
|
||||
docker build -t meshview-docker .
|
||||
```
|
||||
|
||||
## Run Instructions
|
||||
|
||||
Run the container:
|
||||
|
||||
```bash
|
||||
docker run -d --name meshview-docker -p 8081:8081 meshview-docker
|
||||
```
|
||||
|
||||
This maps container port `8081` to your host. The application runs via:
|
||||
|
||||
```bash
|
||||
/app/env/bin/python /app/mvrun.py
|
||||
```
|
||||
|
||||
## Web Interface
|
||||
|
||||
Once the container is running, you can access the MeshView web interface by visiting:
|
||||
|
||||
http://localhost:8081
|
||||
|
||||
If running on a remote server, replace `localhost` with the host's IP or domain name:
|
||||
|
||||
http://<host>:8081
|
||||
|
||||
Ensure that port `8081` is open and not blocked by a firewall or security group.
|
||||
- **Volumes**: `/etc/meshview`, `/var/lib/meshview`, `/var/log/meshview`
|
||||
|
||||
361
docs/ALEMBIC_SETUP.md
Normal file
361
docs/ALEMBIC_SETUP.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Alembic Database Migration Setup
|
||||
|
||||
This document describes the automatic database migration system implemented for MeshView using Alembic.
|
||||
|
||||
## Overview
|
||||
|
||||
The system provides automatic database schema migrations with coordination between the writer app (startdb.py) and reader app (web.py):
|
||||
|
||||
- **Writer App**: Automatically runs pending migrations on startup
|
||||
- **Reader App**: Waits for migrations to complete before starting
|
||||
|
||||
## Architecture
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`meshview/migrations.py`** - Migration management utilities
|
||||
- `run_migrations()` - Runs pending migrations (writer app)
|
||||
- `wait_for_migrations()` - Waits for schema to be current (reader app)
|
||||
- `is_database_up_to_date()` - Checks schema version
|
||||
- Migration status tracking table
|
||||
|
||||
2. **`alembic/`** - Alembic migration directory
|
||||
- `env.py` - Configured for async SQLAlchemy support
|
||||
- `versions/` - Migration scripts directory
|
||||
- `alembic.ini` - Alembic configuration
|
||||
|
||||
3. **Modified Apps**:
|
||||
- `startdb.py` - Writer app that runs migrations before MQTT ingestion
|
||||
- `meshview/web.py` - Reader app that waits for schema updates
|
||||
|
||||
## How It Works - Automatic In-Place Updates
|
||||
|
||||
### ✨ Fully Automatic Operation
|
||||
|
||||
**No manual migration commands needed!** The database schema updates automatically when you:
|
||||
1. Deploy new code with migration files
|
||||
2. Restart the applications
|
||||
|
||||
### Writer App (startdb.py) Startup Sequence
|
||||
|
||||
1. Initialize database connection
|
||||
2. Create migration status tracking table
|
||||
3. Set "migration in progress" flag
|
||||
4. **🔄 Automatically run any pending Alembic migrations** (synchronously)
|
||||
- Detects current schema version
|
||||
- Compares to latest available migration
|
||||
- Runs all pending migrations in sequence
|
||||
- Updates database schema in place
|
||||
5. Clear "migration in progress" flag
|
||||
6. Start MQTT ingestion and other tasks
|
||||
|
||||
### Reader App (web.py) Startup Sequence
|
||||
|
||||
1. Initialize database connection
|
||||
2. **Check database schema version**
|
||||
3. If not up to date:
|
||||
- Wait up to 60 seconds (30 retries × 2 seconds)
|
||||
- Check every 2 seconds for schema updates
|
||||
- Automatically proceeds once writer completes migrations
|
||||
4. Once schema is current, start web server
|
||||
|
||||
### 🎯 Key Point: Zero Manual Steps
|
||||
|
||||
When you deploy new code with migrations:
|
||||
```bash
|
||||
# Just start the apps - migrations happen automatically!
|
||||
./env/bin/python startdb.py # Migrations run here automatically
|
||||
./env/bin/python main.py # Waits for migrations, then starts
|
||||
```
|
||||
|
||||
**The database updates itself!** No need to run `alembic upgrade` manually.
|
||||
|
||||
### Coordination
|
||||
|
||||
The apps coordinate using:
|
||||
- **Alembic version table** (`alembic_version`) - Tracks current schema version
|
||||
- **Migration status table** (`migration_status`) - Optional flag for "in progress" state
|
||||
|
||||
## Creating New Migrations
|
||||
|
||||
### Using the helper script:
|
||||
|
||||
```bash
|
||||
./env/bin/python create_migration.py
|
||||
```
|
||||
|
||||
### Manual creation:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic revision --autogenerate -m "Description of changes"
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Compare current database schema with SQLAlchemy models
|
||||
2. Generate a migration script in `alembic/versions/`
|
||||
3. Automatically detect most schema changes
|
||||
|
||||
### Manual migration (advanced):
|
||||
|
||||
```bash
|
||||
./env/bin/alembic revision -m "Manual migration"
|
||||
```
|
||||
|
||||
Then edit the generated file to add custom migration logic.
|
||||
|
||||
## Running Migrations
|
||||
|
||||
### Automatic (Recommended)
|
||||
|
||||
Migrations run automatically when the writer app starts:
|
||||
|
||||
```bash
|
||||
./env/bin/python startdb.py
|
||||
```
|
||||
|
||||
### Manual
|
||||
|
||||
To run migrations manually:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic upgrade head
|
||||
```
|
||||
|
||||
To downgrade:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic downgrade -1 # Go back one version
|
||||
./env/bin/alembic downgrade base # Go back to beginning
|
||||
```
|
||||
|
||||
## Checking Migration Status
|
||||
|
||||
Check current database version:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic current
|
||||
```
|
||||
|
||||
View migration history:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic history
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Zero Manual Intervention**: Migrations run automatically on startup
|
||||
2. **Safe Coordination**: Reader won't connect to incompatible schema
|
||||
3. **Version Control**: All schema changes tracked in git
|
||||
4. **Rollback Capability**: Can downgrade if needed
|
||||
5. **Auto-generation**: Most migrations created automatically from model changes
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
### Development Process
|
||||
|
||||
1. **Modify SQLAlchemy models** in `meshview/models.py`
|
||||
2. **Create migration**:
|
||||
```bash
|
||||
./env/bin/python create_migration.py
|
||||
```
|
||||
3. **Review generated migration** in `alembic/versions/`
|
||||
4. **Test migration**:
|
||||
- Stop all apps
|
||||
- Start writer app (migrations run automatically)
|
||||
- Start reader app (waits for schema to be current)
|
||||
5. **Commit migration** to version control
|
||||
|
||||
### Production Deployment
|
||||
|
||||
1. **Deploy new code** with migration scripts
|
||||
2. **Start writer app** - Migrations run automatically
|
||||
3. **Start reader app** - Waits for migrations, then starts
|
||||
4. **Monitor logs** for migration success
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migration fails
|
||||
|
||||
Check logs in writer app for error details. To manually fix:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic current # Check current version
|
||||
./env/bin/alembic history # View available versions
|
||||
./env/bin/alembic upgrade head # Try manual upgrade
|
||||
```
|
||||
|
||||
### Reader app won't start (timeout)
|
||||
|
||||
Check if writer app is running and has completed migrations:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic current
|
||||
```
|
||||
|
||||
### Reset to clean state
|
||||
|
||||
⚠️ **Warning: This will lose all data**
|
||||
|
||||
```bash
|
||||
rm packets.db # Or your database file
|
||||
./env/bin/alembic upgrade head # Create fresh schema
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
meshview/
|
||||
├── alembic.ini # Alembic configuration
|
||||
├── alembic/
|
||||
│ ├── env.py # Async-enabled migration runner
|
||||
│ ├── script.py.mako # Migration template
|
||||
│ └── versions/ # Migration scripts
|
||||
│ └── c88468b7ab0b_initial_migration.py
|
||||
├── meshview/
|
||||
│ ├── models.py # SQLAlchemy models (source of truth)
|
||||
│ ├── migrations.py # Migration utilities
|
||||
│ ├── mqtt_database.py # Writer database connection
|
||||
│ └── database.py # Reader database connection
|
||||
├── startdb.py # Writer app (runs migrations)
|
||||
├── main.py # Entry point for reader app
|
||||
└── create_migration.py # Helper script for creating migrations
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Database URL is read from `config.ini`:
|
||||
|
||||
```ini
|
||||
[database]
|
||||
connection_string = sqlite+aiosqlite:///packets.db
|
||||
```
|
||||
|
||||
Alembic automatically uses this configuration through `meshview/migrations.py`.
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Always test migrations** in development before deploying to production
|
||||
2. **Backup database** before running migrations in production
|
||||
3. **Check for data loss** - Some migrations may require data migration logic
|
||||
4. **Coordinate deployments** - Start writer before readers in multi-instance setups
|
||||
5. **Monitor logs** during first startup after deployment
|
||||
|
||||
## Example Migrations
|
||||
|
||||
### Example 1: Generated Initial Migration
|
||||
|
||||
Here's what an auto-generated migration looks like (from comparing models to database):
|
||||
|
||||
```python
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: c88468b7ab0b
|
||||
Revises:
|
||||
Create Date: 2025-01-26 20:56:50.123456
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
# revision identifiers
|
||||
revision = 'c88468b7ab0b'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
def upgrade() -> None:
|
||||
# Upgrade operations
|
||||
op.create_table('node',
|
||||
sa.Column('id', sa.String(), nullable=False),
|
||||
sa.Column('node_id', sa.BigInteger(), nullable=True),
|
||||
# ... more columns
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
def downgrade() -> None:
|
||||
# Downgrade operations
|
||||
op.drop_table('node')
|
||||
```
|
||||
|
||||
### Example 2: Manual Migration Adding a New Table
|
||||
|
||||
We've included an example migration (`1717fa5c6545_add_example_table.py`) that demonstrates how to manually create a new table:
|
||||
|
||||
```python
|
||||
"""Add example table
|
||||
|
||||
Revision ID: 1717fa5c6545
|
||||
Revises: c88468b7ab0b
|
||||
Create Date: 2025-10-26 20:59:04.347066
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create example table with sample columns."""
|
||||
op.create_table(
|
||||
'example',
|
||||
sa.Column('id', sa.Integer(), nullable=False, primary_key=True, autoincrement=True),
|
||||
sa.Column('name', sa.String(length=100), nullable=False),
|
||||
sa.Column('description', sa.Text(), nullable=True),
|
||||
sa.Column('value', sa.Float(), nullable=True),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False,
|
||||
server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create an index on the name column for faster lookups
|
||||
op.create_index('idx_example_name', 'example', ['name'])
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove example table."""
|
||||
op.drop_index('idx_example_name', table_name='example')
|
||||
op.drop_table('example')
|
||||
```
|
||||
|
||||
**Key features demonstrated:**
|
||||
- Various column types (Integer, String, Text, Float, Boolean, DateTime)
|
||||
- Primary key with autoincrement
|
||||
- Nullable and non-nullable columns
|
||||
- Server defaults (for timestamps and booleans)
|
||||
- Creating indexes
|
||||
- Proper downgrade that reverses all changes
|
||||
|
||||
**To test this migration:**
|
||||
|
||||
```bash
|
||||
# Apply the migration
|
||||
./env/bin/alembic upgrade head
|
||||
|
||||
# Check it was applied
|
||||
./env/bin/alembic current
|
||||
|
||||
# Verify table was created
|
||||
sqlite3 packetsPL.db "SELECT sql FROM sqlite_master WHERE type='table' AND name='example';"
|
||||
|
||||
# Roll back the migration
|
||||
./env/bin/alembic downgrade -1
|
||||
|
||||
# Verify table was removed
|
||||
sqlite3 packetsPL.db "SELECT name FROM sqlite_master WHERE type='table' AND name='example';"
|
||||
```
|
||||
|
||||
**To remove this example migration** (after testing):
|
||||
|
||||
```bash
|
||||
# First make sure you're not on this revision
|
||||
./env/bin/alembic downgrade c88468b7ab0b
|
||||
|
||||
# Then delete the migration file
|
||||
rm alembic/versions/1717fa5c6545_add_example_table.py
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [Alembic Documentation](https://alembic.sqlalchemy.org/)
|
||||
- [SQLAlchemy Documentation](https://docs.sqlalchemy.org/)
|
||||
- [Async SQLAlchemy](https://docs.sqlalchemy.org/en/20/orm/extensions/asyncio.html)
|
||||
@@ -111,12 +111,29 @@ Returns a list of packets with optional filters.
|
||||
|
||||
---
|
||||
|
||||
### Notes
|
||||
- All timestamps (`import_time`, `last_seen`) are returned in ISO 8601 format.
|
||||
- `portnum` is an integer representing the packet type.
|
||||
- `payload` is always a UTF-8 decoded string.
|
||||
---
|
||||
|
||||
## 4 Statistics API: GET `/api/stats`
|
||||
## 4. Channels API
|
||||
|
||||
### GET `/api/channels`
|
||||
Returns a list of channels seen in a given time period.
|
||||
|
||||
**Query Parameters**
|
||||
- `period_type` (optional, string): Time granularity (`hour` or `day`). Default: `hour`.
|
||||
- `length` (optional, int): Number of periods to look back. Default: `24`.
|
||||
|
||||
**Response Example**
|
||||
```json
|
||||
{
|
||||
"channels": ["LongFast", "MediumFast", "ShortFast"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Statistics API
|
||||
|
||||
### GET `/api/stats`
|
||||
|
||||
Retrieve packet statistics aggregated by time periods, with optional filtering.
|
||||
|
||||
@@ -157,3 +174,171 @@ Retrieve packet statistics aggregated by time periods, with optional filtering.
|
||||
// more entries...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Edges API
|
||||
|
||||
### GET `/api/edges`
|
||||
Returns network edges (connections between nodes) based on traceroutes and neighbor info.
|
||||
|
||||
**Query Parameters**
|
||||
- `type` (optional, string): Filter by edge type (`traceroute` or `neighbor`). If omitted, returns both types.
|
||||
|
||||
**Response Example**
|
||||
```json
|
||||
{
|
||||
"edges": [
|
||||
{
|
||||
"from": 12345678,
|
||||
"to": 87654321,
|
||||
"type": "traceroute"
|
||||
},
|
||||
{
|
||||
"from": 11111111,
|
||||
"to": 22222222,
|
||||
"type": "neighbor"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Configuration API
|
||||
|
||||
### GET `/api/config`
|
||||
Returns the current site configuration (safe subset exposed to clients).
|
||||
|
||||
**Response Example**
|
||||
```json
|
||||
{
|
||||
"site": {
|
||||
"domain": "meshview.example.com",
|
||||
"language": "en",
|
||||
"title": "Bay Area Mesh",
|
||||
"message": "Real time data from around the bay area",
|
||||
"starting": "/chat",
|
||||
"nodes": "true",
|
||||
"conversations": "true",
|
||||
"everything": "true",
|
||||
"graphs": "true",
|
||||
"stats": "true",
|
||||
"net": "true",
|
||||
"map": "true",
|
||||
"top": "true",
|
||||
"map_top_left_lat": 39.0,
|
||||
"map_top_left_lon": -123.0,
|
||||
"map_bottom_right_lat": 36.0,
|
||||
"map_bottom_right_lon": -121.0,
|
||||
"map_interval": 3,
|
||||
"firehose_interval": 3,
|
||||
"weekly_net_message": "Weekly Mesh check-in message.",
|
||||
"net_tag": "#BayMeshNet",
|
||||
"version": "2.0.8 ~ 10-22-25"
|
||||
},
|
||||
"mqtt": {
|
||||
"server": "mqtt.bayme.sh",
|
||||
"topics": ["msh/US/bayarea/#"]
|
||||
},
|
||||
"cleanup": {
|
||||
"enabled": "false",
|
||||
"days_to_keep": "14",
|
||||
"hour": "2",
|
||||
"minute": "0",
|
||||
"vacuum": "false"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Language/Translations API
|
||||
|
||||
### GET `/api/lang`
|
||||
Returns translation strings for the UI.
|
||||
|
||||
**Query Parameters**
|
||||
- `lang` (optional, string): Language code (e.g., `en`, `es`). Defaults to site language setting.
|
||||
- `section` (optional, string): Specific section to retrieve translations for.
|
||||
|
||||
**Response Example (full)**
|
||||
```json
|
||||
{
|
||||
"chat": {
|
||||
"title": "Chat",
|
||||
"send": "Send"
|
||||
},
|
||||
"map": {
|
||||
"title": "Map",
|
||||
"zoom_in": "Zoom In"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example (section-specific)**
|
||||
Request: `/api/lang?section=chat`
|
||||
```json
|
||||
{
|
||||
"title": "Chat",
|
||||
"send": "Send"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Health Check API
|
||||
|
||||
### GET `/health`
|
||||
Health check endpoint for monitoring, load balancers, and orchestration systems.
|
||||
|
||||
**Response Example (Healthy)**
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"timestamp": "2025-11-03T14:30:00.123456Z",
|
||||
"version": "3.0.0",
|
||||
"git_revision": "6416978",
|
||||
"database": "connected",
|
||||
"database_size": "853.03 MB",
|
||||
"database_size_bytes": 894468096
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example (Unhealthy)**
|
||||
Status Code: `503 Service Unavailable`
|
||||
```json
|
||||
{
|
||||
"status": "unhealthy",
|
||||
"timestamp": "2025-11-03T14:30:00.123456Z",
|
||||
"version": "2.0.8",
|
||||
"git_revision": "6416978",
|
||||
"database": "disconnected"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Version API
|
||||
|
||||
### GET `/version`
|
||||
Returns detailed version information including semver, release date, and git revision.
|
||||
|
||||
**Response Example**
|
||||
```json
|
||||
{
|
||||
"version": "2.0.8",
|
||||
"release_date": "2025-10-22",
|
||||
"git_revision": "6416978a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q",
|
||||
"git_revision_short": "6416978"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
- All timestamps (`import_time`, `last_seen`) are returned in ISO 8601 format.
|
||||
- `portnum` is an integer representing the packet type.
|
||||
- `payload` is always a UTF-8 decoded string.
|
||||
- Node IDs are integers (e.g., `12345678`).
|
||||
146
docs/Database-Changes-With-Alembic.md
Normal file
146
docs/Database-Changes-With-Alembic.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# Database Changes With Alembic
|
||||
|
||||
This guide explains how to make database schema changes in MeshView using Alembic migrations.
|
||||
|
||||
## Overview
|
||||
|
||||
When you need to add, modify, or remove columns from database tables, you must:
|
||||
1. Update the SQLAlchemy model
|
||||
2. Create an Alembic migration
|
||||
3. Let the system automatically apply the migration
|
||||
|
||||
## Step-by-Step Process
|
||||
|
||||
### 1. Update the Model
|
||||
|
||||
Edit `meshview/models.py` to add/modify the column in the appropriate model class:
|
||||
|
||||
```python
|
||||
class Traceroute(Base):
|
||||
__tablename__ = "traceroute"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
# ... existing columns ...
|
||||
route_return: Mapped[bytes] = mapped_column(nullable=True) # New column
|
||||
```
|
||||
|
||||
### 2. Create an Alembic Migration
|
||||
|
||||
Generate a new migration file with a descriptive message:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic revision -m "add route_return to traceroute"
|
||||
```
|
||||
|
||||
This creates a new file in `alembic/versions/` with a unique revision ID.
|
||||
|
||||
### 3. Fill in the Migration
|
||||
|
||||
Edit the generated migration file to implement the actual database changes:
|
||||
|
||||
```python
|
||||
def upgrade() -> None:
|
||||
# Add route_return column to traceroute table
|
||||
with op.batch_alter_table('traceroute', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('route_return', sa.LargeBinary(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Remove route_return column from traceroute table
|
||||
with op.batch_alter_table('traceroute', schema=None) as batch_op:
|
||||
batch_op.drop_column('route_return')
|
||||
```
|
||||
|
||||
### 4. Migration Runs Automatically
|
||||
|
||||
When you restart the application with `mvrun.py`:
|
||||
|
||||
1. The writer process (`startdb.py`) starts up
|
||||
2. It checks if the database schema is up to date
|
||||
3. If new migrations are pending, it runs them automatically
|
||||
4. The reader process (web server) waits for migrations to complete before starting
|
||||
|
||||
**No manual migration command is needed** - the application handles this automatically on startup.
|
||||
|
||||
### 5. Commit Both Files
|
||||
|
||||
Add both files to git:
|
||||
|
||||
```bash
|
||||
git add meshview/models.py
|
||||
git add alembic/versions/ac311b3782a1_add_route_return_to_traceroute.py
|
||||
git commit -m "Add route_return column to traceroute table"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
### SQLite Compatibility
|
||||
|
||||
Always use `batch_alter_table` for SQLite compatibility:
|
||||
|
||||
```python
|
||||
with op.batch_alter_table('table_name', schema=None) as batch_op:
|
||||
batch_op.add_column(...)
|
||||
```
|
||||
|
||||
SQLite has limited ALTER TABLE support, and `batch_alter_table` works around these limitations.
|
||||
|
||||
### Migration Process
|
||||
|
||||
- **Writer process** (`startdb.py`): Runs migrations on startup
|
||||
- **Reader process** (web server in `main.py`): Waits for migrations to complete
|
||||
- Migrations are checked and applied every time the application starts
|
||||
- The system uses a migration status table to coordinate between processes
|
||||
|
||||
### Common Column Types
|
||||
|
||||
```python
|
||||
# Integer
|
||||
column: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
# String
|
||||
column: Mapped[str] = mapped_column(nullable=True)
|
||||
|
||||
# Bytes/Binary
|
||||
column: Mapped[bytes] = mapped_column(nullable=True)
|
||||
|
||||
# DateTime
|
||||
column: Mapped[datetime] = mapped_column(nullable=True)
|
||||
|
||||
# Boolean
|
||||
column: Mapped[bool] = mapped_column(nullable=True)
|
||||
|
||||
# Float
|
||||
column: Mapped[float] = mapped_column(nullable=True)
|
||||
```
|
||||
|
||||
### Migration File Location
|
||||
|
||||
Migrations are stored in: `alembic/versions/`
|
||||
|
||||
Each migration file includes:
|
||||
- Revision ID (unique identifier)
|
||||
- Down revision (previous migration in chain)
|
||||
- Create date
|
||||
- `upgrade()` function (applies changes)
|
||||
- `downgrade()` function (reverts changes)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Migration Not Running
|
||||
|
||||
If migrations don't run automatically:
|
||||
|
||||
1. Check that the database is writable
|
||||
2. Look for errors in the startup logs
|
||||
3. Verify the migration chain is correct (each migration references the previous one)
|
||||
|
||||
### Manual Migration (Not Recommended)
|
||||
|
||||
If you need to manually run migrations for debugging:
|
||||
|
||||
```bash
|
||||
./env/bin/alembic upgrade head
|
||||
```
|
||||
|
||||
However, the application normally handles this automatically.
|
||||
14
docs/README.md
Normal file
14
docs/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Technical Documentation
|
||||
|
||||
This directory contains technical documentation for MeshView that goes beyond initial setup and basic usage.
|
||||
|
||||
These documents are intended for developers, contributors, and advanced users who need deeper insight into the system's architecture, database migrations, API endpoints, and internal workings.
|
||||
|
||||
## Contents
|
||||
|
||||
- [ALEMBIC_SETUP.md](ALEMBIC_SETUP.md) - Database migration setup and management
|
||||
- [TIMESTAMP_MIGRATION.md](TIMESTAMP_MIGRATION.md) - Details on timestamp schema changes
|
||||
- [API_Documentation.md](API_Documentation.md) - REST API endpoints and usage
|
||||
- [CODE_IMPROVEMENTS.md](CODE_IMPROVEMENTS.md) - Suggested code improvements and refactoring ideas
|
||||
|
||||
For initial setup and basic usage instructions, please see the main [README.md](../README.md) in the root directory.
|
||||
193
docs/TIMESTAMP_MIGRATION.md
Normal file
193
docs/TIMESTAMP_MIGRATION.md
Normal file
@@ -0,0 +1,193 @@
|
||||
# High-Resolution Timestamp Migration
|
||||
|
||||
This document describes the implementation of GitHub issue #55: storing high-resolution timestamps as integers in the database for improved performance and query efficiency.
|
||||
|
||||
## Overview
|
||||
|
||||
The meshview database now stores timestamps in two formats:
|
||||
1. **TEXT format** (`import_time`): Human-readable ISO8601 format with microseconds (e.g., `2025-03-12 04:15:56.058038`)
|
||||
2. **INTEGER format** (`import_time_us`): Microseconds since Unix epoch (1970-01-01 00:00:00 UTC)
|
||||
|
||||
The dual format approach provides:
|
||||
- **Backward compatibility**: Existing `import_time` TEXT columns remain unchanged
|
||||
- **Performance**: Fast integer comparisons and math operations
|
||||
- **Precision**: Microsecond resolution for accurate timing
|
||||
- **Efficiency**: Compact storage and fast indexed lookups
|
||||
|
||||
## Database Changes
|
||||
|
||||
### New Columns Added
|
||||
|
||||
Three tables have new `import_time_us` columns:
|
||||
|
||||
1. **packet.import_time_us** (INTEGER)
|
||||
- Stores when the packet was imported into the database
|
||||
- Indexed for fast queries
|
||||
|
||||
2. **packet_seen.import_time_us** (INTEGER)
|
||||
- Stores when the packet_seen record was imported
|
||||
- Indexed for performance
|
||||
|
||||
3. **traceroute.import_time_us** (INTEGER)
|
||||
- Stores when the traceroute was imported
|
||||
- Indexed for fast lookups
|
||||
|
||||
### New Indexes
|
||||
|
||||
The following indexes were created for optimal query performance:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_packet_import_time_us ON packet(import_time_us DESC);
|
||||
CREATE INDEX idx_packet_from_node_time_us ON packet(from_node_id, import_time_us DESC);
|
||||
CREATE INDEX idx_packet_seen_import_time_us ON packet_seen(import_time_us);
|
||||
CREATE INDEX idx_traceroute_import_time_us ON traceroute(import_time_us);
|
||||
```
|
||||
|
||||
## Migration Process
|
||||
|
||||
### For Existing Databases
|
||||
|
||||
Run the migration script to add the new columns and populate them from existing data:
|
||||
|
||||
```bash
|
||||
python migrate_add_timestamp_us.py [database_path]
|
||||
```
|
||||
|
||||
If no path is provided, it defaults to `packets.db` in the current directory.
|
||||
|
||||
The migration script:
|
||||
1. Checks if migration is needed (idempotent)
|
||||
2. Adds `import_time_us` columns to the three tables
|
||||
3. Populates the new columns from existing `import_time` values
|
||||
4. Creates indexes for optimal performance
|
||||
5. Verifies the migration completed successfully
|
||||
|
||||
### For New Databases
|
||||
|
||||
New databases created with the updated schema will automatically include the `import_time_us` columns. The MQTT store module populates both columns when inserting new records.
|
||||
|
||||
## Code Changes
|
||||
|
||||
### Models (meshview/models.py)
|
||||
|
||||
The ORM models now include the new `import_time_us` fields:
|
||||
|
||||
```python
|
||||
class Packet(Base):
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
```
|
||||
|
||||
### MQTT Store (meshview/mqtt_store.py)
|
||||
|
||||
The data ingestion logic now populates both timestamp columns using UTC time:
|
||||
|
||||
```python
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
|
||||
# Both columns are populated
|
||||
import_time=now,
|
||||
import_time_us=now_us,
|
||||
```
|
||||
|
||||
**Important**: All new timestamps use UTC (Coordinated Universal Time) for consistency across time zones.
|
||||
|
||||
## Using the New Timestamps
|
||||
|
||||
### Example Queries
|
||||
|
||||
**Query packets from the last 7 days:**
|
||||
|
||||
```sql
|
||||
-- Old way (slower)
|
||||
SELECT * FROM packet
|
||||
WHERE import_time >= datetime('now', '-7 days');
|
||||
|
||||
-- New way (faster)
|
||||
SELECT * FROM packet
|
||||
WHERE import_time_us >= (strftime('%s', 'now', '-7 days') * 1000000);
|
||||
```
|
||||
|
||||
**Query packets in a specific time range:**
|
||||
|
||||
```sql
|
||||
SELECT * FROM packet
|
||||
WHERE import_time_us BETWEEN 1759254380000000 AND 1759254390000000;
|
||||
```
|
||||
|
||||
**Calculate time differences (in microseconds):**
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
id,
|
||||
(import_time_us - LAG(import_time_us) OVER (ORDER BY import_time_us)) / 1000000.0 as seconds_since_last
|
||||
FROM packet
|
||||
LIMIT 10;
|
||||
```
|
||||
|
||||
### Converting Timestamps
|
||||
|
||||
**From datetime to microseconds (UTC):**
|
||||
```python
|
||||
import datetime
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
```
|
||||
|
||||
**From microseconds to datetime:**
|
||||
```python
|
||||
import datetime
|
||||
timestamp_us = 1759254380813451
|
||||
dt = datetime.datetime.fromtimestamp(timestamp_us / 1_000_000)
|
||||
```
|
||||
|
||||
**In SQL queries:**
|
||||
```sql
|
||||
-- Datetime to microseconds
|
||||
SELECT CAST((strftime('%s', import_time) || substr(import_time, 21, 6)) AS INTEGER);
|
||||
|
||||
-- Microseconds to datetime (approximate)
|
||||
SELECT datetime(import_time_us / 1000000, 'unixepoch');
|
||||
```
|
||||
|
||||
## Performance Benefits
|
||||
|
||||
The integer timestamp columns provide significant performance improvements:
|
||||
|
||||
1. **Faster comparisons**: Integer comparisons are much faster than string/datetime comparisons
|
||||
2. **Smaller index size**: Integer indexes are more compact than datetime indexes
|
||||
3. **Range queries**: BETWEEN operations on integers are highly optimized
|
||||
4. **Math operations**: Easy to calculate time differences, averages, etc.
|
||||
5. **Sorting**: Integer sorting is faster than datetime sorting
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
The original `import_time` TEXT columns remain unchanged:
|
||||
- Existing code continues to work
|
||||
- Human-readable timestamps still available
|
||||
- Gradual migration to new columns possible
|
||||
- No breaking changes for existing queries
|
||||
|
||||
## Future Work
|
||||
|
||||
Future improvements could include:
|
||||
- Migrating queries to use `import_time_us` columns
|
||||
- Deprecating the TEXT `import_time` columns (after transition period)
|
||||
- Adding helper functions for timestamp conversion
|
||||
- Creating views that expose both formats
|
||||
|
||||
## Testing
|
||||
|
||||
The migration was tested on a production database with:
|
||||
- 132,466 packet records
|
||||
- 1,385,659 packet_seen records
|
||||
- 28,414 traceroute records
|
||||
|
||||
All records were successfully migrated with microsecond precision preserved.
|
||||
|
||||
## References
|
||||
|
||||
- GitHub Issue: #55 - Storing High-Resolution Timestamps in SQLite
|
||||
- SQLite datetime functions: https://www.sqlite.org/lang_datefunc.html
|
||||
- Python datetime module: https://docs.python.org/3/library/datetime.html
|
||||
File diff suppressed because one or more lines are too long
@@ -770,6 +770,7 @@ class SharedContact(google.protobuf.message.Message):
|
||||
NODE_NUM_FIELD_NUMBER: builtins.int
|
||||
USER_FIELD_NUMBER: builtins.int
|
||||
SHOULD_IGNORE_FIELD_NUMBER: builtins.int
|
||||
MANUALLY_VERIFIED_FIELD_NUMBER: builtins.int
|
||||
node_num: builtins.int
|
||||
"""
|
||||
The node number of the contact
|
||||
@@ -778,6 +779,10 @@ class SharedContact(google.protobuf.message.Message):
|
||||
"""
|
||||
Add this contact to the blocked / ignored list
|
||||
"""
|
||||
manually_verified: builtins.bool
|
||||
"""
|
||||
Set the IS_KEY_MANUALLY_VERIFIED bit
|
||||
"""
|
||||
@property
|
||||
def user(self) -> meshtastic.protobuf.mesh_pb2.User:
|
||||
"""
|
||||
@@ -790,9 +795,10 @@ class SharedContact(google.protobuf.message.Message):
|
||||
node_num: builtins.int = ...,
|
||||
user: meshtastic.protobuf.mesh_pb2.User | None = ...,
|
||||
should_ignore: builtins.bool = ...,
|
||||
manually_verified: builtins.bool = ...,
|
||||
) -> None: ...
|
||||
def HasField(self, field_name: typing.Literal["user", b"user"]) -> builtins.bool: ...
|
||||
def ClearField(self, field_name: typing.Literal["node_num", b"node_num", "should_ignore", b"should_ignore", "user", b"user"]) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["manually_verified", b"manually_verified", "node_num", b"node_num", "should_ignore", b"should_ignore", "user", b"user"]) -> None: ...
|
||||
|
||||
global___SharedContact = SharedContact
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ from meshtastic.protobuf import channel_pb2 as meshtastic_dot_protobuf_dot_chann
|
||||
from meshtastic.protobuf import config_pb2 as meshtastic_dot_protobuf_dot_config__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/apponly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\"\x81\x01\n\nChannelSet\x12\x36\n\x08settings\x18\x01 \x03(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12;\n\x0blora_config\x18\x02 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfigBb\n\x13\x63om.geeksville.meshB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/apponly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\"\x81\x01\n\nChannelSet\x12\x36\n\x08settings\x18\x01 \x03(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12;\n\x0blora_config\x18\x02 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfigBc\n\x14org.meshtastic.protoB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.apponly_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\rAppOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_CHANNELSET']._serialized_start=128
|
||||
_globals['_CHANNELSET']._serialized_end=257
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/atak.proto\x12\x13meshtastic.protobuf\"\xa5\x02\n\tTAKPacket\x12\x15\n\ris_compressed\x18\x01 \x01(\x08\x12-\n\x07\x63ontact\x18\x02 \x01(\x0b\x32\x1c.meshtastic.protobuf.Contact\x12)\n\x05group\x18\x03 \x01(\x0b\x32\x1a.meshtastic.protobuf.Group\x12+\n\x06status\x18\x04 \x01(\x0b\x32\x1b.meshtastic.protobuf.Status\x12\'\n\x03pli\x18\x05 \x01(\x0b\x32\x18.meshtastic.protobuf.PLIH\x00\x12,\n\x04\x63hat\x18\x06 \x01(\x0b\x32\x1c.meshtastic.protobuf.GeoChatH\x00\x12\x10\n\x06\x64\x65tail\x18\x07 \x01(\x0cH\x00\x42\x11\n\x0fpayload_variant\"\\\n\x07GeoChat\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0f\n\x02to\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0bto_callsign\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x05\n\x03_toB\x0e\n\x0c_to_callsign\"_\n\x05Group\x12-\n\x04role\x18\x01 \x01(\x0e\x32\x1f.meshtastic.protobuf.MemberRole\x12\'\n\x04team\x18\x02 \x01(\x0e\x32\x19.meshtastic.protobuf.Team\"\x19\n\x06Status\x12\x0f\n\x07\x62\x61ttery\x18\x01 \x01(\r\"4\n\x07\x43ontact\x12\x10\n\x08\x63\x61llsign\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65vice_callsign\x18\x02 \x01(\t\"_\n\x03PLI\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\r\n\x05speed\x18\x04 \x01(\r\x12\x0e\n\x06\x63ourse\x18\x05 \x01(\r*\xc0\x01\n\x04Team\x12\x14\n\x10Unspecifed_Color\x10\x00\x12\t\n\x05White\x10\x01\x12\n\n\x06Yellow\x10\x02\x12\n\n\x06Orange\x10\x03\x12\x0b\n\x07Magenta\x10\x04\x12\x07\n\x03Red\x10\x05\x12\n\n\x06Maroon\x10\x06\x12\n\n\x06Purple\x10\x07\x12\r\n\tDark_Blue\x10\x08\x12\x08\n\x04\x42lue\x10\t\x12\x08\n\x04\x43yan\x10\n\x12\x08\n\x04Teal\x10\x0b\x12\t\n\x05Green\x10\x0c\x12\x0e\n\nDark_Green\x10\r\x12\t\n\x05\x42rown\x10\x0e*\x7f\n\nMemberRole\x12\x0e\n\nUnspecifed\x10\x00\x12\x0e\n\nTeamMember\x10\x01\x12\x0c\n\x08TeamLead\x10\x02\x12\x06\n\x02HQ\x10\x03\x12\n\n\x06Sniper\x10\x04\x12\t\n\x05Medic\x10\x05\x12\x13\n\x0f\x46orwardObserver\x10\x06\x12\x07\n\x03RTO\x10\x07\x12\x06\n\x02K9\x10\x08\x42_\n\x13\x63om.geeksville.meshB\nATAKProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/atak.proto\x12\x13meshtastic.protobuf\"\xa5\x02\n\tTAKPacket\x12\x15\n\ris_compressed\x18\x01 \x01(\x08\x12-\n\x07\x63ontact\x18\x02 \x01(\x0b\x32\x1c.meshtastic.protobuf.Contact\x12)\n\x05group\x18\x03 \x01(\x0b\x32\x1a.meshtastic.protobuf.Group\x12+\n\x06status\x18\x04 \x01(\x0b\x32\x1b.meshtastic.protobuf.Status\x12\'\n\x03pli\x18\x05 \x01(\x0b\x32\x18.meshtastic.protobuf.PLIH\x00\x12,\n\x04\x63hat\x18\x06 \x01(\x0b\x32\x1c.meshtastic.protobuf.GeoChatH\x00\x12\x10\n\x06\x64\x65tail\x18\x07 \x01(\x0cH\x00\x42\x11\n\x0fpayload_variant\"\\\n\x07GeoChat\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x0f\n\x02to\x18\x02 \x01(\tH\x00\x88\x01\x01\x12\x18\n\x0bto_callsign\x18\x03 \x01(\tH\x01\x88\x01\x01\x42\x05\n\x03_toB\x0e\n\x0c_to_callsign\"_\n\x05Group\x12-\n\x04role\x18\x01 \x01(\x0e\x32\x1f.meshtastic.protobuf.MemberRole\x12\'\n\x04team\x18\x02 \x01(\x0e\x32\x19.meshtastic.protobuf.Team\"\x19\n\x06Status\x12\x0f\n\x07\x62\x61ttery\x18\x01 \x01(\r\"4\n\x07\x43ontact\x12\x10\n\x08\x63\x61llsign\x18\x01 \x01(\t\x12\x17\n\x0f\x64\x65vice_callsign\x18\x02 \x01(\t\"_\n\x03PLI\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\r\n\x05speed\x18\x04 \x01(\r\x12\x0e\n\x06\x63ourse\x18\x05 \x01(\r*\xc0\x01\n\x04Team\x12\x14\n\x10Unspecifed_Color\x10\x00\x12\t\n\x05White\x10\x01\x12\n\n\x06Yellow\x10\x02\x12\n\n\x06Orange\x10\x03\x12\x0b\n\x07Magenta\x10\x04\x12\x07\n\x03Red\x10\x05\x12\n\n\x06Maroon\x10\x06\x12\n\n\x06Purple\x10\x07\x12\r\n\tDark_Blue\x10\x08\x12\x08\n\x04\x42lue\x10\t\x12\x08\n\x04\x43yan\x10\n\x12\x08\n\x04Teal\x10\x0b\x12\t\n\x05Green\x10\x0c\x12\x0e\n\nDark_Green\x10\r\x12\t\n\x05\x42rown\x10\x0e*\x7f\n\nMemberRole\x12\x0e\n\nUnspecifed\x10\x00\x12\x0e\n\nTeamMember\x10\x01\x12\x0c\n\x08TeamLead\x10\x02\x12\x06\n\x02HQ\x10\x03\x12\n\n\x06Sniper\x10\x04\x12\t\n\x05Medic\x10\x05\x12\x13\n\x0f\x46orwardObserver\x10\x06\x12\x07\n\x03RTO\x10\x07\x12\x06\n\x02K9\x10\x08\x42`\n\x14org.meshtastic.protoB\nATAKProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.atak_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\nATAKProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\nATAKProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_TEAM']._serialized_start=721
|
||||
_globals['_TEAM']._serialized_end=913
|
||||
_globals['_MEMBERROLE']._serialized_start=915
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(meshtastic/protobuf/cannedmessages.proto\x12\x13meshtastic.protobuf\"-\n\x19\x43\x61nnedMessageModuleConfig\x12\x10\n\x08messages\x18\x01 \x01(\tBn\n\x13\x63om.geeksville.meshB\x19\x43\x61nnedMessageConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n(meshtastic/protobuf/cannedmessages.proto\x12\x13meshtastic.protobuf\"-\n\x19\x43\x61nnedMessageModuleConfig\x12\x10\n\x08messages\x18\x01 \x01(\tBo\n\x14org.meshtastic.protoB\x19\x43\x61nnedMessageConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.cannedmessages_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\031CannedMessageConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\031CannedMessageConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_CANNEDMESSAGEMODULECONFIG']._serialized_start=65
|
||||
_globals['_CANNEDMESSAGEMODULECONFIG']._serialized_end=110
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -13,22 +13,22 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/channel.proto\x12\x13meshtastic.protobuf\"\xc1\x01\n\x0f\x43hannelSettings\x12\x17\n\x0b\x63hannel_num\x18\x01 \x01(\rB\x02\x18\x01\x12\x0b\n\x03psk\x18\x02 \x01(\x0c\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\n\n\x02id\x18\x04 \x01(\x07\x12\x16\n\x0euplink_enabled\x18\x05 \x01(\x08\x12\x18\n\x10\x64ownlink_enabled\x18\x06 \x01(\x08\x12<\n\x0fmodule_settings\x18\x07 \x01(\x0b\x32#.meshtastic.protobuf.ModuleSettings\"E\n\x0eModuleSettings\x12\x1a\n\x12position_precision\x18\x01 \x01(\r\x12\x17\n\x0fis_client_muted\x18\x02 \x01(\x08\"\xb3\x01\n\x07\x43hannel\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x36\n\x08settings\x18\x02 \x01(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12/\n\x04role\x18\x03 \x01(\x0e\x32!.meshtastic.protobuf.Channel.Role\"0\n\x04Role\x12\x0c\n\x08\x44ISABLED\x10\x00\x12\x0b\n\x07PRIMARY\x10\x01\x12\r\n\tSECONDARY\x10\x02\x42\x62\n\x13\x63om.geeksville.meshB\rChannelProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n!meshtastic/protobuf/channel.proto\x12\x13meshtastic.protobuf\"\xc1\x01\n\x0f\x43hannelSettings\x12\x17\n\x0b\x63hannel_num\x18\x01 \x01(\rB\x02\x18\x01\x12\x0b\n\x03psk\x18\x02 \x01(\x0c\x12\x0c\n\x04name\x18\x03 \x01(\t\x12\n\n\x02id\x18\x04 \x01(\x07\x12\x16\n\x0euplink_enabled\x18\x05 \x01(\x08\x12\x18\n\x10\x64ownlink_enabled\x18\x06 \x01(\x08\x12<\n\x0fmodule_settings\x18\x07 \x01(\x0b\x32#.meshtastic.protobuf.ModuleSettings\">\n\x0eModuleSettings\x12\x1a\n\x12position_precision\x18\x01 \x01(\r\x12\x10\n\x08is_muted\x18\x02 \x01(\x08\"\xb3\x01\n\x07\x43hannel\x12\r\n\x05index\x18\x01 \x01(\x05\x12\x36\n\x08settings\x18\x02 \x01(\x0b\x32$.meshtastic.protobuf.ChannelSettings\x12/\n\x04role\x18\x03 \x01(\x0e\x32!.meshtastic.protobuf.Channel.Role\"0\n\x04Role\x12\x0c\n\x08\x44ISABLED\x10\x00\x12\x0b\n\x07PRIMARY\x10\x01\x12\r\n\tSECONDARY\x10\x02\x42\x63\n\x14org.meshtastic.protoB\rChannelProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.channel_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\rChannelProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\rChannelProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_CHANNELSETTINGS.fields_by_name['channel_num']._options = None
|
||||
_CHANNELSETTINGS.fields_by_name['channel_num']._serialized_options = b'\030\001'
|
||||
_globals['_CHANNELSETTINGS']._serialized_start=59
|
||||
_globals['_CHANNELSETTINGS']._serialized_end=252
|
||||
_globals['_MODULESETTINGS']._serialized_start=254
|
||||
_globals['_MODULESETTINGS']._serialized_end=323
|
||||
_globals['_CHANNEL']._serialized_start=326
|
||||
_globals['_CHANNEL']._serialized_end=505
|
||||
_globals['_CHANNEL_ROLE']._serialized_start=457
|
||||
_globals['_CHANNEL_ROLE']._serialized_end=505
|
||||
_globals['_MODULESETTINGS']._serialized_end=316
|
||||
_globals['_CHANNEL']._serialized_start=319
|
||||
_globals['_CHANNEL']._serialized_end=498
|
||||
_globals['_CHANNEL_ROLE']._serialized_start=450
|
||||
_globals['_CHANNEL_ROLE']._serialized_end=498
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -127,23 +127,23 @@ class ModuleSettings(google.protobuf.message.Message):
|
||||
DESCRIPTOR: google.protobuf.descriptor.Descriptor
|
||||
|
||||
POSITION_PRECISION_FIELD_NUMBER: builtins.int
|
||||
IS_CLIENT_MUTED_FIELD_NUMBER: builtins.int
|
||||
IS_MUTED_FIELD_NUMBER: builtins.int
|
||||
position_precision: builtins.int
|
||||
"""
|
||||
Bits of precision for the location sent in position packets.
|
||||
"""
|
||||
is_client_muted: builtins.bool
|
||||
is_muted: builtins.bool
|
||||
"""
|
||||
Controls whether or not the phone / clients should mute the current channel
|
||||
Controls whether or not the client / device should mute the current channel
|
||||
Useful for noisy public channels you don't necessarily want to disable
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
position_precision: builtins.int = ...,
|
||||
is_client_muted: builtins.bool = ...,
|
||||
is_muted: builtins.bool = ...,
|
||||
) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["is_client_muted", b"is_client_muted", "position_precision", b"position_precision"]) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["is_muted", b"is_muted", "position_precision", b"position_precision"]) -> None: ...
|
||||
|
||||
global___ModuleSettings = ModuleSettings
|
||||
|
||||
|
||||
@@ -15,14 +15,14 @@ from meshtastic.protobuf import localonly_pb2 as meshtastic_dot_protobuf_dot_loc
|
||||
from meshtastic.protobuf import mesh_pb2 as meshtastic_dot_protobuf_dot_mesh__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/clientonly.proto\x12\x13meshtastic.protobuf\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"\xc4\x03\n\rDeviceProfile\x12\x16\n\tlong_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nshort_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0b\x63hannel_url\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x35\n\x06\x63onfig\x18\x04 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfigH\x03\x88\x01\x01\x12\x42\n\rmodule_config\x18\x05 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfigH\x04\x88\x01\x01\x12:\n\x0e\x66ixed_position\x18\x06 \x01(\x0b\x32\x1d.meshtastic.protobuf.PositionH\x05\x88\x01\x01\x12\x15\n\x08ringtone\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x1c\n\x0f\x63\x61nned_messages\x18\x08 \x01(\tH\x07\x88\x01\x01\x42\x0c\n\n_long_nameB\r\n\x0b_short_nameB\x0e\n\x0c_channel_urlB\t\n\x07_configB\x10\n\x0e_module_configB\x11\n\x0f_fixed_positionB\x0b\n\t_ringtoneB\x12\n\x10_canned_messagesBe\n\x13\x63om.geeksville.meshB\x10\x43lientOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/clientonly.proto\x12\x13meshtastic.protobuf\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"\xc4\x03\n\rDeviceProfile\x12\x16\n\tlong_name\x18\x01 \x01(\tH\x00\x88\x01\x01\x12\x17\n\nshort_name\x18\x02 \x01(\tH\x01\x88\x01\x01\x12\x18\n\x0b\x63hannel_url\x18\x03 \x01(\tH\x02\x88\x01\x01\x12\x35\n\x06\x63onfig\x18\x04 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfigH\x03\x88\x01\x01\x12\x42\n\rmodule_config\x18\x05 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfigH\x04\x88\x01\x01\x12:\n\x0e\x66ixed_position\x18\x06 \x01(\x0b\x32\x1d.meshtastic.protobuf.PositionH\x05\x88\x01\x01\x12\x15\n\x08ringtone\x18\x07 \x01(\tH\x06\x88\x01\x01\x12\x1c\n\x0f\x63\x61nned_messages\x18\x08 \x01(\tH\x07\x88\x01\x01\x42\x0c\n\n_long_nameB\r\n\x0b_short_nameB\x0e\n\x0c_channel_urlB\t\n\x07_configB\x10\n\x0e_module_configB\x11\n\x0f_fixed_positionB\x0b\n\t_ringtoneB\x12\n\x10_canned_messagesBf\n\x14org.meshtastic.protoB\x10\x43lientOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.clientonly_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\020ClientOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\020ClientOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_DEVICEPROFILE']._serialized_start=131
|
||||
_globals['_DEVICEPROFILE']._serialized_end=583
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -64,6 +64,7 @@ class Config(google.protobuf.message.Message):
|
||||
Description: Infrastructure node for extending network coverage by relaying messages with minimal overhead. Not visible in Nodes list.
|
||||
Technical Details: Mesh packets will simply be rebroadcasted over this node. Nodes configured with this role will not originate NodeInfo, Position, Telemetry
|
||||
or any other packet type. They will simply rebroadcast any mesh packets on the same frequency, channel num, spread factor, and coding rate.
|
||||
Deprecated in v2.7.11 because it creates "holes" in the mesh rebroadcast chain.
|
||||
"""
|
||||
TRACKER: Config.DeviceConfig._Role.ValueType # 5
|
||||
"""
|
||||
@@ -155,6 +156,7 @@ class Config(google.protobuf.message.Message):
|
||||
Description: Infrastructure node for extending network coverage by relaying messages with minimal overhead. Not visible in Nodes list.
|
||||
Technical Details: Mesh packets will simply be rebroadcasted over this node. Nodes configured with this role will not originate NodeInfo, Position, Telemetry
|
||||
or any other packet type. They will simply rebroadcast any mesh packets on the same frequency, channel num, spread factor, and coding rate.
|
||||
Deprecated in v2.7.11 because it creates "holes" in the mesh rebroadcast chain.
|
||||
"""
|
||||
TRACKER: Config.DeviceConfig.Role.ValueType # 5
|
||||
"""
|
||||
@@ -938,80 +940,20 @@ class Config(google.protobuf.message.Message):
|
||||
|
||||
DESCRIPTOR: google.protobuf.descriptor.Descriptor
|
||||
|
||||
class _GpsCoordinateFormat:
|
||||
class _DeprecatedGpsCoordinateFormat:
|
||||
ValueType = typing.NewType("ValueType", builtins.int)
|
||||
V: typing_extensions.TypeAlias = ValueType
|
||||
|
||||
class _GpsCoordinateFormatEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[Config.DisplayConfig._GpsCoordinateFormat.ValueType], builtins.type):
|
||||
class _DeprecatedGpsCoordinateFormatEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[Config.DisplayConfig._DeprecatedGpsCoordinateFormat.ValueType], builtins.type):
|
||||
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
|
||||
DEC: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 0
|
||||
UNUSED: Config.DisplayConfig._DeprecatedGpsCoordinateFormat.ValueType # 0
|
||||
|
||||
class DeprecatedGpsCoordinateFormat(_DeprecatedGpsCoordinateFormat, metaclass=_DeprecatedGpsCoordinateFormatEnumTypeWrapper):
|
||||
"""
|
||||
GPS coordinates are displayed in the normal decimal degrees format:
|
||||
DD.DDDDDD DDD.DDDDDD
|
||||
"""
|
||||
DMS: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 1
|
||||
"""
|
||||
GPS coordinates are displayed in the degrees minutes seconds format:
|
||||
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
|
||||
"""
|
||||
UTM: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 2
|
||||
"""
|
||||
Universal Transverse Mercator format:
|
||||
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
|
||||
"""
|
||||
MGRS: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 3
|
||||
"""
|
||||
Military Grid Reference System format:
|
||||
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
|
||||
E is easting, N is northing
|
||||
"""
|
||||
OLC: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 4
|
||||
"""
|
||||
Open Location Code (aka Plus Codes).
|
||||
"""
|
||||
OSGR: Config.DisplayConfig._GpsCoordinateFormat.ValueType # 5
|
||||
"""
|
||||
Ordnance Survey Grid Reference (the National Grid System of the UK).
|
||||
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
|
||||
E is the easting, N is the northing
|
||||
Deprecated in 2.7.4: Unused
|
||||
"""
|
||||
|
||||
class GpsCoordinateFormat(_GpsCoordinateFormat, metaclass=_GpsCoordinateFormatEnumTypeWrapper):
|
||||
"""
|
||||
How the GPS coordinates are displayed on the OLED screen.
|
||||
"""
|
||||
|
||||
DEC: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 0
|
||||
"""
|
||||
GPS coordinates are displayed in the normal decimal degrees format:
|
||||
DD.DDDDDD DDD.DDDDDD
|
||||
"""
|
||||
DMS: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 1
|
||||
"""
|
||||
GPS coordinates are displayed in the degrees minutes seconds format:
|
||||
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
|
||||
"""
|
||||
UTM: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 2
|
||||
"""
|
||||
Universal Transverse Mercator format:
|
||||
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
|
||||
"""
|
||||
MGRS: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 3
|
||||
"""
|
||||
Military Grid Reference System format:
|
||||
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
|
||||
E is easting, N is northing
|
||||
"""
|
||||
OLC: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 4
|
||||
"""
|
||||
Open Location Code (aka Plus Codes).
|
||||
"""
|
||||
OSGR: Config.DisplayConfig.GpsCoordinateFormat.ValueType # 5
|
||||
"""
|
||||
Ordnance Survey Grid Reference (the National Grid System of the UK).
|
||||
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
|
||||
E is the easting, N is the northing
|
||||
"""
|
||||
UNUSED: Config.DisplayConfig.DeprecatedGpsCoordinateFormat.ValueType # 0
|
||||
|
||||
class _DisplayUnits:
|
||||
ValueType = typing.NewType("ValueType", builtins.int)
|
||||
@@ -1221,12 +1163,13 @@ class Config(google.protobuf.message.Message):
|
||||
WAKE_ON_TAP_OR_MOTION_FIELD_NUMBER: builtins.int
|
||||
COMPASS_ORIENTATION_FIELD_NUMBER: builtins.int
|
||||
USE_12H_CLOCK_FIELD_NUMBER: builtins.int
|
||||
USE_LONG_NODE_NAME_FIELD_NUMBER: builtins.int
|
||||
screen_on_secs: builtins.int
|
||||
"""
|
||||
Number of seconds the screen stays on after pressing the user button or receiving a message
|
||||
0 for default of one minute MAXUINT for always on
|
||||
"""
|
||||
gps_format: global___Config.DisplayConfig.GpsCoordinateFormat.ValueType
|
||||
gps_format: global___Config.DisplayConfig.DeprecatedGpsCoordinateFormat.ValueType
|
||||
"""
|
||||
Deprecated in 2.7.4: Unused
|
||||
How the GPS coordinates are formatted on the OLED screen.
|
||||
@@ -1274,11 +1217,16 @@ class Config(google.protobuf.message.Message):
|
||||
If false (default), the device will display the time in 24-hour format on screen.
|
||||
If true, the device will display the time in 12-hour format on screen.
|
||||
"""
|
||||
use_long_node_name: builtins.bool
|
||||
"""
|
||||
If false (default), the device will use short names for various display screens.
|
||||
If true, node names will show in long format
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
screen_on_secs: builtins.int = ...,
|
||||
gps_format: global___Config.DisplayConfig.GpsCoordinateFormat.ValueType = ...,
|
||||
gps_format: global___Config.DisplayConfig.DeprecatedGpsCoordinateFormat.ValueType = ...,
|
||||
auto_screen_carousel_secs: builtins.int = ...,
|
||||
compass_north_top: builtins.bool = ...,
|
||||
flip_screen: builtins.bool = ...,
|
||||
@@ -1289,8 +1237,9 @@ class Config(google.protobuf.message.Message):
|
||||
wake_on_tap_or_motion: builtins.bool = ...,
|
||||
compass_orientation: global___Config.DisplayConfig.CompassOrientation.ValueType = ...,
|
||||
use_12h_clock: builtins.bool = ...,
|
||||
use_long_node_name: builtins.bool = ...,
|
||||
) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["auto_screen_carousel_secs", b"auto_screen_carousel_secs", "compass_north_top", b"compass_north_top", "compass_orientation", b"compass_orientation", "displaymode", b"displaymode", "flip_screen", b"flip_screen", "gps_format", b"gps_format", "heading_bold", b"heading_bold", "oled", b"oled", "screen_on_secs", b"screen_on_secs", "units", b"units", "use_12h_clock", b"use_12h_clock", "wake_on_tap_or_motion", b"wake_on_tap_or_motion"]) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["auto_screen_carousel_secs", b"auto_screen_carousel_secs", "compass_north_top", b"compass_north_top", "compass_orientation", b"compass_orientation", "displaymode", b"displaymode", "flip_screen", b"flip_screen", "gps_format", b"gps_format", "heading_bold", b"heading_bold", "oled", b"oled", "screen_on_secs", b"screen_on_secs", "units", b"units", "use_12h_clock", b"use_12h_clock", "use_long_node_name", b"use_long_node_name", "wake_on_tap_or_motion", b"wake_on_tap_or_motion"]) -> None: ...
|
||||
|
||||
@typing.final
|
||||
class LoRaConfig(google.protobuf.message.Message):
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n+meshtastic/protobuf/connection_status.proto\x12\x13meshtastic.protobuf\"\xd5\x02\n\x16\x44\x65viceConnectionStatus\x12<\n\x04wifi\x18\x01 \x01(\x0b\x32).meshtastic.protobuf.WifiConnectionStatusH\x00\x88\x01\x01\x12\x44\n\x08\x65thernet\x18\x02 \x01(\x0b\x32-.meshtastic.protobuf.EthernetConnectionStatusH\x01\x88\x01\x01\x12\x46\n\tbluetooth\x18\x03 \x01(\x0b\x32..meshtastic.protobuf.BluetoothConnectionStatusH\x02\x88\x01\x01\x12@\n\x06serial\x18\x04 \x01(\x0b\x32+.meshtastic.protobuf.SerialConnectionStatusH\x03\x88\x01\x01\x42\x07\n\x05_wifiB\x0b\n\t_ethernetB\x0c\n\n_bluetoothB\t\n\x07_serial\"p\n\x14WifiConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\x12\x0c\n\x04ssid\x18\x02 \x01(\t\x12\x0c\n\x04rssi\x18\x03 \x01(\x05\"X\n\x18\x45thernetConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\"{\n\x17NetworkConnectionStatus\x12\x12\n\nip_address\x18\x01 \x01(\x07\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x12\x19\n\x11is_mqtt_connected\x18\x03 \x01(\x08\x12\x1b\n\x13is_syslog_connected\x18\x04 \x01(\x08\"L\n\x19\x42luetoothConnectionStatus\x12\x0b\n\x03pin\x18\x01 \x01(\r\x12\x0c\n\x04rssi\x18\x02 \x01(\x05\x12\x14\n\x0cis_connected\x18\x03 \x01(\x08\"<\n\x16SerialConnectionStatus\x12\x0c\n\x04\x62\x61ud\x18\x01 \x01(\r\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x42\x65\n\x13\x63om.geeksville.meshB\x10\x43onnStatusProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n+meshtastic/protobuf/connection_status.proto\x12\x13meshtastic.protobuf\"\xd5\x02\n\x16\x44\x65viceConnectionStatus\x12<\n\x04wifi\x18\x01 \x01(\x0b\x32).meshtastic.protobuf.WifiConnectionStatusH\x00\x88\x01\x01\x12\x44\n\x08\x65thernet\x18\x02 \x01(\x0b\x32-.meshtastic.protobuf.EthernetConnectionStatusH\x01\x88\x01\x01\x12\x46\n\tbluetooth\x18\x03 \x01(\x0b\x32..meshtastic.protobuf.BluetoothConnectionStatusH\x02\x88\x01\x01\x12@\n\x06serial\x18\x04 \x01(\x0b\x32+.meshtastic.protobuf.SerialConnectionStatusH\x03\x88\x01\x01\x42\x07\n\x05_wifiB\x0b\n\t_ethernetB\x0c\n\n_bluetoothB\t\n\x07_serial\"p\n\x14WifiConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\x12\x0c\n\x04ssid\x18\x02 \x01(\t\x12\x0c\n\x04rssi\x18\x03 \x01(\x05\"X\n\x18\x45thernetConnectionStatus\x12<\n\x06status\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.NetworkConnectionStatus\"{\n\x17NetworkConnectionStatus\x12\x12\n\nip_address\x18\x01 \x01(\x07\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x12\x19\n\x11is_mqtt_connected\x18\x03 \x01(\x08\x12\x1b\n\x13is_syslog_connected\x18\x04 \x01(\x08\"L\n\x19\x42luetoothConnectionStatus\x12\x0b\n\x03pin\x18\x01 \x01(\r\x12\x0c\n\x04rssi\x18\x02 \x01(\x05\x12\x14\n\x0cis_connected\x18\x03 \x01(\x08\"<\n\x16SerialConnectionStatus\x12\x0c\n\x04\x62\x61ud\x18\x01 \x01(\r\x12\x14\n\x0cis_connected\x18\x02 \x01(\x08\x42\x66\n\x14org.meshtastic.protoB\x10\x43onnStatusProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.connection_status_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\020ConnStatusProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\020ConnStatusProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_DEVICECONNECTIONSTATUS']._serialized_start=69
|
||||
_globals['_DEVICECONNECTIONSTATUS']._serialized_end=410
|
||||
_globals['_WIFICONNECTIONSTATUS']._serialized_start=412
|
||||
|
||||
@@ -13,28 +13,30 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/device_ui.proto\x12\x13meshtastic.protobuf\"\xda\x04\n\x0e\x44\x65viceUIConfig\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x19\n\x11screen_brightness\x18\x02 \x01(\r\x12\x16\n\x0escreen_timeout\x18\x03 \x01(\r\x12\x13\n\x0bscreen_lock\x18\x04 \x01(\x08\x12\x15\n\rsettings_lock\x18\x05 \x01(\x08\x12\x10\n\x08pin_code\x18\x06 \x01(\r\x12)\n\x05theme\x18\x07 \x01(\x0e\x32\x1a.meshtastic.protobuf.Theme\x12\x15\n\ralert_enabled\x18\x08 \x01(\x08\x12\x16\n\x0e\x62\x61nner_enabled\x18\t \x01(\x08\x12\x14\n\x0cring_tone_id\x18\n \x01(\r\x12/\n\x08language\x18\x0b \x01(\x0e\x32\x1d.meshtastic.protobuf.Language\x12\x34\n\x0bnode_filter\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.NodeFilter\x12:\n\x0enode_highlight\x18\r \x01(\x0b\x32\".meshtastic.protobuf.NodeHighlight\x12\x18\n\x10\x63\x61libration_data\x18\x0e \x01(\x0c\x12*\n\x08map_data\x18\x0f \x01(\x0b\x32\x18.meshtastic.protobuf.Map\x12\x36\n\x0c\x63ompass_mode\x18\x10 \x01(\x0e\x32 .meshtastic.protobuf.CompassMode\x12\x18\n\x10screen_rgb_color\x18\x11 \x01(\r\x12\x1b\n\x13is_clockface_analog\x18\x12 \x01(\x08\"\xa7\x01\n\nNodeFilter\x12\x16\n\x0eunknown_switch\x18\x01 \x01(\x08\x12\x16\n\x0eoffline_switch\x18\x02 \x01(\x08\x12\x19\n\x11public_key_switch\x18\x03 \x01(\x08\x12\x11\n\thops_away\x18\x04 \x01(\x05\x12\x17\n\x0fposition_switch\x18\x05 \x01(\x08\x12\x11\n\tnode_name\x18\x06 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\x05\"~\n\rNodeHighlight\x12\x13\n\x0b\x63hat_switch\x18\x01 \x01(\x08\x12\x17\n\x0fposition_switch\x18\x02 \x01(\x08\x12\x18\n\x10telemetry_switch\x18\x03 \x01(\x08\x12\x12\n\niaq_switch\x18\x04 \x01(\x08\x12\x11\n\tnode_name\x18\x05 \x01(\t\"=\n\x08GeoPoint\x12\x0c\n\x04zoom\x18\x01 \x01(\x05\x12\x10\n\x08latitude\x18\x02 \x01(\x05\x12\x11\n\tlongitude\x18\x03 \x01(\x05\"U\n\x03Map\x12+\n\x04home\x18\x01 \x01(\x0b\x32\x1d.meshtastic.protobuf.GeoPoint\x12\r\n\x05style\x18\x02 \x01(\t\x12\x12\n\nfollow_gps\x18\x03 \x01(\x08*>\n\x0b\x43ompassMode\x12\x0b\n\x07\x44YNAMIC\x10\x00\x12\x0e\n\nFIXED_RING\x10\x01\x12\x12\n\x0e\x46REEZE_HEADING\x10\x02*%\n\x05Theme\x12\x08\n\x04\x44\x41RK\x10\x00\x12\t\n\x05LIGHT\x10\x01\x12\x07\n\x03RED\x10\x02*\xb4\x02\n\x08Language\x12\x0b\n\x07\x45NGLISH\x10\x00\x12\n\n\x06\x46RENCH\x10\x01\x12\n\n\x06GERMAN\x10\x02\x12\x0b\n\x07ITALIAN\x10\x03\x12\x0e\n\nPORTUGUESE\x10\x04\x12\x0b\n\x07SPANISH\x10\x05\x12\x0b\n\x07SWEDISH\x10\x06\x12\x0b\n\x07\x46INNISH\x10\x07\x12\n\n\x06POLISH\x10\x08\x12\x0b\n\x07TURKISH\x10\t\x12\x0b\n\x07SERBIAN\x10\n\x12\x0b\n\x07RUSSIAN\x10\x0b\x12\t\n\x05\x44UTCH\x10\x0c\x12\t\n\x05GREEK\x10\r\x12\r\n\tNORWEGIAN\x10\x0e\x12\r\n\tSLOVENIAN\x10\x0f\x12\r\n\tUKRAINIAN\x10\x10\x12\r\n\tBULGARIAN\x10\x11\x12\t\n\x05\x43ZECH\x10\x12\x12\x16\n\x12SIMPLIFIED_CHINESE\x10\x1e\x12\x17\n\x13TRADITIONAL_CHINESE\x10\x1f\x42\x63\n\x13\x63om.geeksville.meshB\x0e\x44\x65viceUIProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/device_ui.proto\x12\x13meshtastic.protobuf\"\xff\x05\n\x0e\x44\x65viceUIConfig\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x19\n\x11screen_brightness\x18\x02 \x01(\r\x12\x16\n\x0escreen_timeout\x18\x03 \x01(\r\x12\x13\n\x0bscreen_lock\x18\x04 \x01(\x08\x12\x15\n\rsettings_lock\x18\x05 \x01(\x08\x12\x10\n\x08pin_code\x18\x06 \x01(\r\x12)\n\x05theme\x18\x07 \x01(\x0e\x32\x1a.meshtastic.protobuf.Theme\x12\x15\n\ralert_enabled\x18\x08 \x01(\x08\x12\x16\n\x0e\x62\x61nner_enabled\x18\t \x01(\x08\x12\x14\n\x0cring_tone_id\x18\n \x01(\r\x12/\n\x08language\x18\x0b \x01(\x0e\x32\x1d.meshtastic.protobuf.Language\x12\x34\n\x0bnode_filter\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.NodeFilter\x12:\n\x0enode_highlight\x18\r \x01(\x0b\x32\".meshtastic.protobuf.NodeHighlight\x12\x18\n\x10\x63\x61libration_data\x18\x0e \x01(\x0c\x12*\n\x08map_data\x18\x0f \x01(\x0b\x32\x18.meshtastic.protobuf.Map\x12\x36\n\x0c\x63ompass_mode\x18\x10 \x01(\x0e\x32 .meshtastic.protobuf.CompassMode\x12\x18\n\x10screen_rgb_color\x18\x11 \x01(\r\x12\x1b\n\x13is_clockface_analog\x18\x12 \x01(\x08\x12K\n\ngps_format\x18\x13 \x01(\x0e\x32\x37.meshtastic.protobuf.DeviceUIConfig.GpsCoordinateFormat\"V\n\x13GpsCoordinateFormat\x12\x07\n\x03\x44\x45\x43\x10\x00\x12\x07\n\x03\x44MS\x10\x01\x12\x07\n\x03UTM\x10\x02\x12\x08\n\x04MGRS\x10\x03\x12\x07\n\x03OLC\x10\x04\x12\x08\n\x04OSGR\x10\x05\x12\x07\n\x03MLS\x10\x06\"\xa7\x01\n\nNodeFilter\x12\x16\n\x0eunknown_switch\x18\x01 \x01(\x08\x12\x16\n\x0eoffline_switch\x18\x02 \x01(\x08\x12\x19\n\x11public_key_switch\x18\x03 \x01(\x08\x12\x11\n\thops_away\x18\x04 \x01(\x05\x12\x17\n\x0fposition_switch\x18\x05 \x01(\x08\x12\x11\n\tnode_name\x18\x06 \x01(\t\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\x05\"~\n\rNodeHighlight\x12\x13\n\x0b\x63hat_switch\x18\x01 \x01(\x08\x12\x17\n\x0fposition_switch\x18\x02 \x01(\x08\x12\x18\n\x10telemetry_switch\x18\x03 \x01(\x08\x12\x12\n\niaq_switch\x18\x04 \x01(\x08\x12\x11\n\tnode_name\x18\x05 \x01(\t\"=\n\x08GeoPoint\x12\x0c\n\x04zoom\x18\x01 \x01(\x05\x12\x10\n\x08latitude\x18\x02 \x01(\x05\x12\x11\n\tlongitude\x18\x03 \x01(\x05\"U\n\x03Map\x12+\n\x04home\x18\x01 \x01(\x0b\x32\x1d.meshtastic.protobuf.GeoPoint\x12\r\n\x05style\x18\x02 \x01(\t\x12\x12\n\nfollow_gps\x18\x03 \x01(\x08*>\n\x0b\x43ompassMode\x12\x0b\n\x07\x44YNAMIC\x10\x00\x12\x0e\n\nFIXED_RING\x10\x01\x12\x12\n\x0e\x46REEZE_HEADING\x10\x02*%\n\x05Theme\x12\x08\n\x04\x44\x41RK\x10\x00\x12\t\n\x05LIGHT\x10\x01\x12\x07\n\x03RED\x10\x02*\xc0\x02\n\x08Language\x12\x0b\n\x07\x45NGLISH\x10\x00\x12\n\n\x06\x46RENCH\x10\x01\x12\n\n\x06GERMAN\x10\x02\x12\x0b\n\x07ITALIAN\x10\x03\x12\x0e\n\nPORTUGUESE\x10\x04\x12\x0b\n\x07SPANISH\x10\x05\x12\x0b\n\x07SWEDISH\x10\x06\x12\x0b\n\x07\x46INNISH\x10\x07\x12\n\n\x06POLISH\x10\x08\x12\x0b\n\x07TURKISH\x10\t\x12\x0b\n\x07SERBIAN\x10\n\x12\x0b\n\x07RUSSIAN\x10\x0b\x12\t\n\x05\x44UTCH\x10\x0c\x12\t\n\x05GREEK\x10\r\x12\r\n\tNORWEGIAN\x10\x0e\x12\r\n\tSLOVENIAN\x10\x0f\x12\r\n\tUKRAINIAN\x10\x10\x12\r\n\tBULGARIAN\x10\x11\x12\t\n\x05\x43ZECH\x10\x12\x12\n\n\x06\x44\x41NISH\x10\x13\x12\x16\n\x12SIMPLIFIED_CHINESE\x10\x1e\x12\x17\n\x13TRADITIONAL_CHINESE\x10\x1f\x42\x64\n\x14org.meshtastic.protoB\x0e\x44\x65viceUIProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.device_ui_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016DeviceUIProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_COMPASSMODE']._serialized_start=1113
|
||||
_globals['_COMPASSMODE']._serialized_end=1175
|
||||
_globals['_THEME']._serialized_start=1177
|
||||
_globals['_THEME']._serialized_end=1214
|
||||
_globals['_LANGUAGE']._serialized_start=1217
|
||||
_globals['_LANGUAGE']._serialized_end=1525
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016DeviceUIProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_COMPASSMODE']._serialized_start=1278
|
||||
_globals['_COMPASSMODE']._serialized_end=1340
|
||||
_globals['_THEME']._serialized_start=1342
|
||||
_globals['_THEME']._serialized_end=1379
|
||||
_globals['_LANGUAGE']._serialized_start=1382
|
||||
_globals['_LANGUAGE']._serialized_end=1702
|
||||
_globals['_DEVICEUICONFIG']._serialized_start=61
|
||||
_globals['_DEVICEUICONFIG']._serialized_end=663
|
||||
_globals['_NODEFILTER']._serialized_start=666
|
||||
_globals['_NODEFILTER']._serialized_end=833
|
||||
_globals['_NODEHIGHLIGHT']._serialized_start=835
|
||||
_globals['_NODEHIGHLIGHT']._serialized_end=961
|
||||
_globals['_GEOPOINT']._serialized_start=963
|
||||
_globals['_GEOPOINT']._serialized_end=1024
|
||||
_globals['_MAP']._serialized_start=1026
|
||||
_globals['_MAP']._serialized_end=1111
|
||||
_globals['_DEVICEUICONFIG']._serialized_end=828
|
||||
_globals['_DEVICEUICONFIG_GPSCOORDINATEFORMAT']._serialized_start=742
|
||||
_globals['_DEVICEUICONFIG_GPSCOORDINATEFORMAT']._serialized_end=828
|
||||
_globals['_NODEFILTER']._serialized_start=831
|
||||
_globals['_NODEFILTER']._serialized_end=998
|
||||
_globals['_NODEHIGHLIGHT']._serialized_start=1000
|
||||
_globals['_NODEHIGHLIGHT']._serialized_end=1126
|
||||
_globals['_GEOPOINT']._serialized_start=1128
|
||||
_globals['_GEOPOINT']._serialized_end=1189
|
||||
_globals['_MAP']._serialized_start=1191
|
||||
_globals['_MAP']._serialized_end=1276
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -169,6 +169,10 @@ class _LanguageEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumT
|
||||
"""
|
||||
Czech
|
||||
"""
|
||||
DANISH: _Language.ValueType # 19
|
||||
"""
|
||||
Danish
|
||||
"""
|
||||
SIMPLIFIED_CHINESE: _Language.ValueType # 30
|
||||
"""
|
||||
Simplified Chinese (experimental)
|
||||
@@ -259,6 +263,10 @@ CZECH: Language.ValueType # 18
|
||||
"""
|
||||
Czech
|
||||
"""
|
||||
DANISH: Language.ValueType # 19
|
||||
"""
|
||||
Danish
|
||||
"""
|
||||
SIMPLIFIED_CHINESE: Language.ValueType # 30
|
||||
"""
|
||||
Simplified Chinese (experimental)
|
||||
@@ -277,6 +285,91 @@ class DeviceUIConfig(google.protobuf.message.Message):
|
||||
|
||||
DESCRIPTOR: google.protobuf.descriptor.Descriptor
|
||||
|
||||
class _GpsCoordinateFormat:
|
||||
ValueType = typing.NewType("ValueType", builtins.int)
|
||||
V: typing_extensions.TypeAlias = ValueType
|
||||
|
||||
class _GpsCoordinateFormatEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._EnumTypeWrapper[DeviceUIConfig._GpsCoordinateFormat.ValueType], builtins.type):
|
||||
DESCRIPTOR: google.protobuf.descriptor.EnumDescriptor
|
||||
DEC: DeviceUIConfig._GpsCoordinateFormat.ValueType # 0
|
||||
"""
|
||||
GPS coordinates are displayed in the normal decimal degrees format:
|
||||
DD.DDDDDD DDD.DDDDDD
|
||||
"""
|
||||
DMS: DeviceUIConfig._GpsCoordinateFormat.ValueType # 1
|
||||
"""
|
||||
GPS coordinates are displayed in the degrees minutes seconds format:
|
||||
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
|
||||
"""
|
||||
UTM: DeviceUIConfig._GpsCoordinateFormat.ValueType # 2
|
||||
"""
|
||||
Universal Transverse Mercator format:
|
||||
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
|
||||
"""
|
||||
MGRS: DeviceUIConfig._GpsCoordinateFormat.ValueType # 3
|
||||
"""
|
||||
Military Grid Reference System format:
|
||||
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
|
||||
E is easting, N is northing
|
||||
"""
|
||||
OLC: DeviceUIConfig._GpsCoordinateFormat.ValueType # 4
|
||||
"""
|
||||
Open Location Code (aka Plus Codes).
|
||||
"""
|
||||
OSGR: DeviceUIConfig._GpsCoordinateFormat.ValueType # 5
|
||||
"""
|
||||
Ordnance Survey Grid Reference (the National Grid System of the UK).
|
||||
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
|
||||
E is the easting, N is the northing
|
||||
"""
|
||||
MLS: DeviceUIConfig._GpsCoordinateFormat.ValueType # 6
|
||||
"""
|
||||
Maidenhead Locator System
|
||||
Described here: https://en.wikipedia.org/wiki/Maidenhead_Locator_System
|
||||
"""
|
||||
|
||||
class GpsCoordinateFormat(_GpsCoordinateFormat, metaclass=_GpsCoordinateFormatEnumTypeWrapper):
|
||||
"""
|
||||
How the GPS coordinates are displayed on the OLED screen.
|
||||
"""
|
||||
|
||||
DEC: DeviceUIConfig.GpsCoordinateFormat.ValueType # 0
|
||||
"""
|
||||
GPS coordinates are displayed in the normal decimal degrees format:
|
||||
DD.DDDDDD DDD.DDDDDD
|
||||
"""
|
||||
DMS: DeviceUIConfig.GpsCoordinateFormat.ValueType # 1
|
||||
"""
|
||||
GPS coordinates are displayed in the degrees minutes seconds format:
|
||||
DD°MM'SS"C DDD°MM'SS"C, where C is the compass point representing the locations quadrant
|
||||
"""
|
||||
UTM: DeviceUIConfig.GpsCoordinateFormat.ValueType # 2
|
||||
"""
|
||||
Universal Transverse Mercator format:
|
||||
ZZB EEEEEE NNNNNNN, where Z is zone, B is band, E is easting, N is northing
|
||||
"""
|
||||
MGRS: DeviceUIConfig.GpsCoordinateFormat.ValueType # 3
|
||||
"""
|
||||
Military Grid Reference System format:
|
||||
ZZB CD EEEEE NNNNN, where Z is zone, B is band, C is the east 100k square, D is the north 100k square,
|
||||
E is easting, N is northing
|
||||
"""
|
||||
OLC: DeviceUIConfig.GpsCoordinateFormat.ValueType # 4
|
||||
"""
|
||||
Open Location Code (aka Plus Codes).
|
||||
"""
|
||||
OSGR: DeviceUIConfig.GpsCoordinateFormat.ValueType # 5
|
||||
"""
|
||||
Ordnance Survey Grid Reference (the National Grid System of the UK).
|
||||
Format: AB EEEEE NNNNN, where A is the east 100k square, B is the north 100k square,
|
||||
E is the easting, N is the northing
|
||||
"""
|
||||
MLS: DeviceUIConfig.GpsCoordinateFormat.ValueType # 6
|
||||
"""
|
||||
Maidenhead Locator System
|
||||
Described here: https://en.wikipedia.org/wiki/Maidenhead_Locator_System
|
||||
"""
|
||||
|
||||
VERSION_FIELD_NUMBER: builtins.int
|
||||
SCREEN_BRIGHTNESS_FIELD_NUMBER: builtins.int
|
||||
SCREEN_TIMEOUT_FIELD_NUMBER: builtins.int
|
||||
@@ -295,6 +388,7 @@ class DeviceUIConfig(google.protobuf.message.Message):
|
||||
COMPASS_MODE_FIELD_NUMBER: builtins.int
|
||||
SCREEN_RGB_COLOR_FIELD_NUMBER: builtins.int
|
||||
IS_CLOCKFACE_ANALOG_FIELD_NUMBER: builtins.int
|
||||
GPS_FORMAT_FIELD_NUMBER: builtins.int
|
||||
version: builtins.int
|
||||
"""
|
||||
A version integer used to invalidate saved files when we make incompatible changes.
|
||||
@@ -345,6 +439,10 @@ class DeviceUIConfig(google.protobuf.message.Message):
|
||||
Clockface analog style
|
||||
true for analog clockface, false for digital clockface
|
||||
"""
|
||||
gps_format: global___DeviceUIConfig.GpsCoordinateFormat.ValueType
|
||||
"""
|
||||
How the GPS coordinates are formatted on the OLED screen.
|
||||
"""
|
||||
@property
|
||||
def node_filter(self) -> global___NodeFilter:
|
||||
"""
|
||||
@@ -384,9 +482,10 @@ class DeviceUIConfig(google.protobuf.message.Message):
|
||||
compass_mode: global___CompassMode.ValueType = ...,
|
||||
screen_rgb_color: builtins.int = ...,
|
||||
is_clockface_analog: builtins.bool = ...,
|
||||
gps_format: global___DeviceUIConfig.GpsCoordinateFormat.ValueType = ...,
|
||||
) -> None: ...
|
||||
def HasField(self, field_name: typing.Literal["map_data", b"map_data", "node_filter", b"node_filter", "node_highlight", b"node_highlight"]) -> builtins.bool: ...
|
||||
def ClearField(self, field_name: typing.Literal["alert_enabled", b"alert_enabled", "banner_enabled", b"banner_enabled", "calibration_data", b"calibration_data", "compass_mode", b"compass_mode", "is_clockface_analog", b"is_clockface_analog", "language", b"language", "map_data", b"map_data", "node_filter", b"node_filter", "node_highlight", b"node_highlight", "pin_code", b"pin_code", "ring_tone_id", b"ring_tone_id", "screen_brightness", b"screen_brightness", "screen_lock", b"screen_lock", "screen_rgb_color", b"screen_rgb_color", "screen_timeout", b"screen_timeout", "settings_lock", b"settings_lock", "theme", b"theme", "version", b"version"]) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["alert_enabled", b"alert_enabled", "banner_enabled", b"banner_enabled", "calibration_data", b"calibration_data", "compass_mode", b"compass_mode", "gps_format", b"gps_format", "is_clockface_analog", b"is_clockface_analog", "language", b"language", "map_data", b"map_data", "node_filter", b"node_filter", "node_highlight", b"node_highlight", "pin_code", b"pin_code", "ring_tone_id", b"ring_tone_id", "screen_brightness", b"screen_brightness", "screen_lock", b"screen_lock", "screen_rgb_color", b"screen_rgb_color", "screen_timeout", b"screen_timeout", "settings_lock", b"settings_lock", "theme", b"theme", "version", b"version"]) -> None: ...
|
||||
|
||||
global___DeviceUIConfig = DeviceUIConfig
|
||||
|
||||
|
||||
@@ -19,14 +19,14 @@ from meshtastic.protobuf import telemetry_pb2 as meshtastic_dot_protobuf_dot_tel
|
||||
from meshtastic.protobuf import nanopb_pb2 as meshtastic_dot_protobuf_dot_nanopb__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/deviceonly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\x1a#meshtastic/protobuf/telemetry.proto\x1a meshtastic/protobuf/nanopb.proto\"\x99\x01\n\x0cPositionLite\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\x0c\n\x04time\x18\x04 \x01(\x07\x12@\n\x0flocation_source\x18\x05 \x01(\x0e\x32\'.meshtastic.protobuf.Position.LocSource\"\x94\x02\n\x08UserLite\x12\x13\n\x07macaddr\x18\x01 \x01(\x0c\x42\x02\x18\x01\x12\x11\n\tlong_name\x18\x02 \x01(\t\x12\x12\n\nshort_name\x18\x03 \x01(\t\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x13\n\x0bis_licensed\x18\x05 \x01(\x08\x12;\n\x04role\x18\x06 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x12\n\npublic_key\x18\x07 \x01(\x0c\x12\x1c\n\x0fis_unmessagable\x18\t \x01(\x08H\x00\x88\x01\x01\x42\x12\n\x10_is_unmessagable\"\xf0\x02\n\x0cNodeInfoLite\x12\x0b\n\x03num\x18\x01 \x01(\r\x12+\n\x04user\x18\x02 \x01(\x0b\x32\x1d.meshtastic.protobuf.UserLite\x12\x33\n\x08position\x18\x03 \x01(\x0b\x32!.meshtastic.protobuf.PositionLite\x12\x0b\n\x03snr\x18\x04 \x01(\x02\x12\x12\n\nlast_heard\x18\x05 \x01(\x07\x12:\n\x0e\x64\x65vice_metrics\x18\x06 \x01(\x0b\x32\".meshtastic.protobuf.DeviceMetrics\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\r\x12\x10\n\x08via_mqtt\x18\x08 \x01(\x08\x12\x16\n\thops_away\x18\t \x01(\rH\x00\x88\x01\x01\x12\x13\n\x0bis_favorite\x18\n \x01(\x08\x12\x12\n\nis_ignored\x18\x0b \x01(\x08\x12\x10\n\x08next_hop\x18\x0c \x01(\r\x12\x10\n\x08\x62itfield\x18\r \x01(\rB\x0c\n\n_hops_away\"\xa1\x03\n\x0b\x44\x65viceState\x12\x30\n\x07my_node\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.MyNodeInfo\x12(\n\x05owner\x18\x03 \x01(\x0b\x32\x19.meshtastic.protobuf.User\x12\x36\n\rreceive_queue\x18\x05 \x03(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x0f\n\x07version\x18\x08 \x01(\r\x12\x38\n\x0frx_text_message\x18\x07 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x13\n\x07no_save\x18\t \x01(\x08\x42\x02\x18\x01\x12\x19\n\rdid_gps_reset\x18\x0b \x01(\x08\x42\x02\x18\x01\x12\x34\n\x0brx_waypoint\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12M\n\x19node_remote_hardware_pins\x18\r \x03(\x0b\x32*.meshtastic.protobuf.NodeRemoteHardwarePin\"}\n\x0cNodeDatabase\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\\\n\x05nodes\x18\x02 \x03(\x0b\x32!.meshtastic.protobuf.NodeInfoLiteB*\x92?\'\x92\x01$std::vector<meshtastic_NodeInfoLite>\"N\n\x0b\x43hannelFile\x12.\n\x08\x63hannels\x18\x01 \x03(\x0b\x32\x1c.meshtastic.protobuf.Channel\x12\x0f\n\x07version\x18\x02 \x01(\r\"\x86\x02\n\x11\x42\x61\x63kupPreferences\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x11\n\ttimestamp\x18\x02 \x01(\x07\x12\x30\n\x06\x63onfig\x18\x03 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfig\x12=\n\rmodule_config\x18\x04 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfig\x12\x32\n\x08\x63hannels\x18\x05 \x01(\x0b\x32 .meshtastic.protobuf.ChannelFile\x12(\n\x05owner\x18\x06 \x01(\x0b\x32\x19.meshtastic.protobuf.UserBm\n\x13\x63om.geeksville.meshB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x92?\x0b\xc2\x01\x08<vector>b\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n$meshtastic/protobuf/deviceonly.proto\x12\x13meshtastic.protobuf\x1a!meshtastic/protobuf/channel.proto\x1a meshtastic/protobuf/config.proto\x1a#meshtastic/protobuf/localonly.proto\x1a\x1emeshtastic/protobuf/mesh.proto\x1a#meshtastic/protobuf/telemetry.proto\x1a meshtastic/protobuf/nanopb.proto\"\x99\x01\n\x0cPositionLite\x12\x12\n\nlatitude_i\x18\x01 \x01(\x0f\x12\x13\n\x0blongitude_i\x18\x02 \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x03 \x01(\x05\x12\x0c\n\x04time\x18\x04 \x01(\x07\x12@\n\x0flocation_source\x18\x05 \x01(\x0e\x32\'.meshtastic.protobuf.Position.LocSource\"\x94\x02\n\x08UserLite\x12\x13\n\x07macaddr\x18\x01 \x01(\x0c\x42\x02\x18\x01\x12\x11\n\tlong_name\x18\x02 \x01(\t\x12\x12\n\nshort_name\x18\x03 \x01(\t\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x13\n\x0bis_licensed\x18\x05 \x01(\x08\x12;\n\x04role\x18\x06 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x12\n\npublic_key\x18\x07 \x01(\x0c\x12\x1c\n\x0fis_unmessagable\x18\t \x01(\x08H\x00\x88\x01\x01\x42\x12\n\x10_is_unmessagable\"\xf0\x02\n\x0cNodeInfoLite\x12\x0b\n\x03num\x18\x01 \x01(\r\x12+\n\x04user\x18\x02 \x01(\x0b\x32\x1d.meshtastic.protobuf.UserLite\x12\x33\n\x08position\x18\x03 \x01(\x0b\x32!.meshtastic.protobuf.PositionLite\x12\x0b\n\x03snr\x18\x04 \x01(\x02\x12\x12\n\nlast_heard\x18\x05 \x01(\x07\x12:\n\x0e\x64\x65vice_metrics\x18\x06 \x01(\x0b\x32\".meshtastic.protobuf.DeviceMetrics\x12\x0f\n\x07\x63hannel\x18\x07 \x01(\r\x12\x10\n\x08via_mqtt\x18\x08 \x01(\x08\x12\x16\n\thops_away\x18\t \x01(\rH\x00\x88\x01\x01\x12\x13\n\x0bis_favorite\x18\n \x01(\x08\x12\x12\n\nis_ignored\x18\x0b \x01(\x08\x12\x10\n\x08next_hop\x18\x0c \x01(\r\x12\x10\n\x08\x62itfield\x18\r \x01(\rB\x0c\n\n_hops_away\"\xa1\x03\n\x0b\x44\x65viceState\x12\x30\n\x07my_node\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.MyNodeInfo\x12(\n\x05owner\x18\x03 \x01(\x0b\x32\x19.meshtastic.protobuf.User\x12\x36\n\rreceive_queue\x18\x05 \x03(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x0f\n\x07version\x18\x08 \x01(\r\x12\x38\n\x0frx_text_message\x18\x07 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x13\n\x07no_save\x18\t \x01(\x08\x42\x02\x18\x01\x12\x19\n\rdid_gps_reset\x18\x0b \x01(\x08\x42\x02\x18\x01\x12\x34\n\x0brx_waypoint\x18\x0c \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12M\n\x19node_remote_hardware_pins\x18\r \x03(\x0b\x32*.meshtastic.protobuf.NodeRemoteHardwarePin\"}\n\x0cNodeDatabase\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\\\n\x05nodes\x18\x02 \x03(\x0b\x32!.meshtastic.protobuf.NodeInfoLiteB*\x92?\'\x92\x01$std::vector<meshtastic_NodeInfoLite>\"N\n\x0b\x43hannelFile\x12.\n\x08\x63hannels\x18\x01 \x03(\x0b\x32\x1c.meshtastic.protobuf.Channel\x12\x0f\n\x07version\x18\x02 \x01(\r\"\x86\x02\n\x11\x42\x61\x63kupPreferences\x12\x0f\n\x07version\x18\x01 \x01(\r\x12\x11\n\ttimestamp\x18\x02 \x01(\x07\x12\x30\n\x06\x63onfig\x18\x03 \x01(\x0b\x32 .meshtastic.protobuf.LocalConfig\x12=\n\rmodule_config\x18\x04 \x01(\x0b\x32&.meshtastic.protobuf.LocalModuleConfig\x12\x32\n\x08\x63hannels\x18\x05 \x01(\x0b\x32 .meshtastic.protobuf.ChannelFile\x12(\n\x05owner\x18\x06 \x01(\x0b\x32\x19.meshtastic.protobuf.UserBn\n\x14org.meshtastic.protoB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x92?\x0b\xc2\x01\x08<vector>b\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.deviceonly_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000\222?\013\302\001\010<vector>'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\nDeviceOnlyZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000\222?\013\302\001\010<vector>'
|
||||
_USERLITE.fields_by_name['macaddr']._options = None
|
||||
_USERLITE.fields_by_name['macaddr']._serialized_options = b'\030\001'
|
||||
_DEVICESTATE.fields_by_name['no_save']._options = None
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%meshtastic/protobuf/interdevice.proto\x12\x13meshtastic.protobuf\"s\n\nSensorData\x12.\n\x04type\x18\x01 \x01(\x0e\x32 .meshtastic.protobuf.MessageType\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x16\n\x0cuint32_value\x18\x03 \x01(\rH\x00\x42\x06\n\x04\x64\x61ta\"_\n\x12InterdeviceMessage\x12\x0e\n\x04nmea\x18\x01 \x01(\tH\x00\x12\x31\n\x06sensor\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.SensorDataH\x00\x42\x06\n\x04\x64\x61ta*\xd5\x01\n\x0bMessageType\x12\x07\n\x03\x41\x43K\x10\x00\x12\x15\n\x10\x43OLLECT_INTERVAL\x10\xa0\x01\x12\x0c\n\x07\x42\x45\x45P_ON\x10\xa1\x01\x12\r\n\x08\x42\x45\x45P_OFF\x10\xa2\x01\x12\r\n\x08SHUTDOWN\x10\xa3\x01\x12\r\n\x08POWER_ON\x10\xa4\x01\x12\x0f\n\nSCD41_TEMP\x10\xb0\x01\x12\x13\n\x0eSCD41_HUMIDITY\x10\xb1\x01\x12\x0e\n\tSCD41_CO2\x10\xb2\x01\x12\x0f\n\nAHT20_TEMP\x10\xb3\x01\x12\x13\n\x0e\x41HT20_HUMIDITY\x10\xb4\x01\x12\x0f\n\nTVOC_INDEX\x10\xb5\x01\x42\x66\n\x13\x63om.geeksville.meshB\x11InterdeviceProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n%meshtastic/protobuf/interdevice.proto\x12\x13meshtastic.protobuf\"s\n\nSensorData\x12.\n\x04type\x18\x01 \x01(\x0e\x32 .meshtastic.protobuf.MessageType\x12\x15\n\x0b\x66loat_value\x18\x02 \x01(\x02H\x00\x12\x16\n\x0cuint32_value\x18\x03 \x01(\rH\x00\x42\x06\n\x04\x64\x61ta\"_\n\x12InterdeviceMessage\x12\x0e\n\x04nmea\x18\x01 \x01(\tH\x00\x12\x31\n\x06sensor\x18\x02 \x01(\x0b\x32\x1f.meshtastic.protobuf.SensorDataH\x00\x42\x06\n\x04\x64\x61ta*\xd5\x01\n\x0bMessageType\x12\x07\n\x03\x41\x43K\x10\x00\x12\x15\n\x10\x43OLLECT_INTERVAL\x10\xa0\x01\x12\x0c\n\x07\x42\x45\x45P_ON\x10\xa1\x01\x12\r\n\x08\x42\x45\x45P_OFF\x10\xa2\x01\x12\r\n\x08SHUTDOWN\x10\xa3\x01\x12\r\n\x08POWER_ON\x10\xa4\x01\x12\x0f\n\nSCD41_TEMP\x10\xb0\x01\x12\x13\n\x0eSCD41_HUMIDITY\x10\xb1\x01\x12\x0e\n\tSCD41_CO2\x10\xb2\x01\x12\x0f\n\nAHT20_TEMP\x10\xb3\x01\x12\x13\n\x0e\x41HT20_HUMIDITY\x10\xb4\x01\x12\x0f\n\nTVOC_INDEX\x10\xb5\x01\x42g\n\x14org.meshtastic.protoB\x11InterdeviceProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.interdevice_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\021InterdeviceProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\021InterdeviceProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_MESSAGETYPE']._serialized_start=277
|
||||
_globals['_MESSAGETYPE']._serialized_end=490
|
||||
_globals['_SENSORDATA']._serialized_start=62
|
||||
|
||||
@@ -15,14 +15,14 @@ from meshtastic.protobuf import config_pb2 as meshtastic_dot_protobuf_dot_config
|
||||
from meshtastic.protobuf import module_config_pb2 as meshtastic_dot_protobuf_dot_module__config__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/localonly.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\'meshtastic/protobuf/module_config.proto\"\xfa\x03\n\x0bLocalConfig\x12\x38\n\x06\x64\x65vice\x18\x01 \x01(\x0b\x32(.meshtastic.protobuf.Config.DeviceConfig\x12<\n\x08position\x18\x02 \x01(\x0b\x32*.meshtastic.protobuf.Config.PositionConfig\x12\x36\n\x05power\x18\x03 \x01(\x0b\x32\'.meshtastic.protobuf.Config.PowerConfig\x12:\n\x07network\x18\x04 \x01(\x0b\x32).meshtastic.protobuf.Config.NetworkConfig\x12:\n\x07\x64isplay\x18\x05 \x01(\x0b\x32).meshtastic.protobuf.Config.DisplayConfig\x12\x34\n\x04lora\x18\x06 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfig\x12>\n\tbluetooth\x18\x07 \x01(\x0b\x32+.meshtastic.protobuf.Config.BluetoothConfig\x12\x0f\n\x07version\x18\x08 \x01(\r\x12<\n\x08security\x18\t \x01(\x0b\x32*.meshtastic.protobuf.Config.SecurityConfig\"\xf0\x07\n\x11LocalModuleConfig\x12:\n\x04mqtt\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.ModuleConfig.MQTTConfig\x12>\n\x06serial\x18\x02 \x01(\x0b\x32..meshtastic.protobuf.ModuleConfig.SerialConfig\x12[\n\x15\x65xternal_notification\x18\x03 \x01(\x0b\x32<.meshtastic.protobuf.ModuleConfig.ExternalNotificationConfig\x12K\n\rstore_forward\x18\x04 \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.StoreForwardConfig\x12\x45\n\nrange_test\x18\x05 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.RangeTestConfig\x12\x44\n\ttelemetry\x18\x06 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.TelemetryConfig\x12M\n\x0e\x63\x61nned_message\x18\x07 \x01(\x0b\x32\x35.meshtastic.protobuf.ModuleConfig.CannedMessageConfig\x12<\n\x05\x61udio\x18\t \x01(\x0b\x32-.meshtastic.protobuf.ModuleConfig.AudioConfig\x12O\n\x0fremote_hardware\x18\n \x01(\x0b\x32\x36.meshtastic.protobuf.ModuleConfig.RemoteHardwareConfig\x12K\n\rneighbor_info\x18\x0b \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.NeighborInfoConfig\x12Q\n\x10\x61mbient_lighting\x18\x0c \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.AmbientLightingConfig\x12Q\n\x10\x64\x65tection_sensor\x18\r \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.DetectionSensorConfig\x12\x46\n\npaxcounter\x18\x0e \x01(\x0b\x32\x32.meshtastic.protobuf.ModuleConfig.PaxcounterConfig\x12\x0f\n\x07version\x18\x08 \x01(\rBd\n\x13\x63om.geeksville.meshB\x0fLocalOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n#meshtastic/protobuf/localonly.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\'meshtastic/protobuf/module_config.proto\"\xfa\x03\n\x0bLocalConfig\x12\x38\n\x06\x64\x65vice\x18\x01 \x01(\x0b\x32(.meshtastic.protobuf.Config.DeviceConfig\x12<\n\x08position\x18\x02 \x01(\x0b\x32*.meshtastic.protobuf.Config.PositionConfig\x12\x36\n\x05power\x18\x03 \x01(\x0b\x32\'.meshtastic.protobuf.Config.PowerConfig\x12:\n\x07network\x18\x04 \x01(\x0b\x32).meshtastic.protobuf.Config.NetworkConfig\x12:\n\x07\x64isplay\x18\x05 \x01(\x0b\x32).meshtastic.protobuf.Config.DisplayConfig\x12\x34\n\x04lora\x18\x06 \x01(\x0b\x32&.meshtastic.protobuf.Config.LoRaConfig\x12>\n\tbluetooth\x18\x07 \x01(\x0b\x32+.meshtastic.protobuf.Config.BluetoothConfig\x12\x0f\n\x07version\x18\x08 \x01(\r\x12<\n\x08security\x18\t \x01(\x0b\x32*.meshtastic.protobuf.Config.SecurityConfig\"\xf0\x07\n\x11LocalModuleConfig\x12:\n\x04mqtt\x18\x01 \x01(\x0b\x32,.meshtastic.protobuf.ModuleConfig.MQTTConfig\x12>\n\x06serial\x18\x02 \x01(\x0b\x32..meshtastic.protobuf.ModuleConfig.SerialConfig\x12[\n\x15\x65xternal_notification\x18\x03 \x01(\x0b\x32<.meshtastic.protobuf.ModuleConfig.ExternalNotificationConfig\x12K\n\rstore_forward\x18\x04 \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.StoreForwardConfig\x12\x45\n\nrange_test\x18\x05 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.RangeTestConfig\x12\x44\n\ttelemetry\x18\x06 \x01(\x0b\x32\x31.meshtastic.protobuf.ModuleConfig.TelemetryConfig\x12M\n\x0e\x63\x61nned_message\x18\x07 \x01(\x0b\x32\x35.meshtastic.protobuf.ModuleConfig.CannedMessageConfig\x12<\n\x05\x61udio\x18\t \x01(\x0b\x32-.meshtastic.protobuf.ModuleConfig.AudioConfig\x12O\n\x0fremote_hardware\x18\n \x01(\x0b\x32\x36.meshtastic.protobuf.ModuleConfig.RemoteHardwareConfig\x12K\n\rneighbor_info\x18\x0b \x01(\x0b\x32\x34.meshtastic.protobuf.ModuleConfig.NeighborInfoConfig\x12Q\n\x10\x61mbient_lighting\x18\x0c \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.AmbientLightingConfig\x12Q\n\x10\x64\x65tection_sensor\x18\r \x01(\x0b\x32\x37.meshtastic.protobuf.ModuleConfig.DetectionSensorConfig\x12\x46\n\npaxcounter\x18\x0e \x01(\x0b\x32\x32.meshtastic.protobuf.ModuleConfig.PaxcounterConfig\x12\x0f\n\x07version\x18\x08 \x01(\rBe\n\x14org.meshtastic.protoB\x0fLocalOnlyProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.localonly_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\017LocalOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\017LocalOnlyProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_LOCALCONFIG']._serialized_start=136
|
||||
_globals['_LOCALCONFIG']._serialized_end=642
|
||||
_globals['_LOCALMODULECONFIG']._serialized_start=645
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -453,9 +453,9 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
|
||||
"""
|
||||
Seeed Tracker L1 EINK driver
|
||||
"""
|
||||
QWANTZ_TINY_ARMS: _HardwareModel.ValueType # 101
|
||||
MUZI_R1_NEO: _HardwareModel.ValueType # 101
|
||||
"""
|
||||
Reserved ID for future and past use
|
||||
Muzi Works R1 Neo
|
||||
"""
|
||||
T_DECK_PRO: _HardwareModel.ValueType # 102
|
||||
"""
|
||||
@@ -465,9 +465,10 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
|
||||
"""
|
||||
Lilygo TLora Pager
|
||||
"""
|
||||
GAT562_MESH_TRIAL_TRACKER: _HardwareModel.ValueType # 104
|
||||
M5STACK_RESERVED: _HardwareModel.ValueType # 104
|
||||
"""
|
||||
GAT562 Mesh Trial Tracker
|
||||
M5Stack Reserved
|
||||
0x68
|
||||
"""
|
||||
WISMESH_TAG: _HardwareModel.ValueType # 105
|
||||
"""
|
||||
@@ -494,6 +495,34 @@ class _HardwareModelEnumTypeWrapper(google.protobuf.internal.enum_type_wrapper._
|
||||
"""
|
||||
New Heltec LoRA32 with ESP32-S3 CPU
|
||||
"""
|
||||
M5STACK_C6L: _HardwareModel.ValueType # 111
|
||||
"""
|
||||
M5Stack C6L
|
||||
"""
|
||||
M5STACK_CARDPUTER_ADV: _HardwareModel.ValueType # 112
|
||||
"""
|
||||
M5Stack Cardputer Adv
|
||||
"""
|
||||
HELTEC_WIRELESS_TRACKER_V2: _HardwareModel.ValueType # 113
|
||||
"""
|
||||
ESP32S3 main controller with GPS and TFT screen.
|
||||
"""
|
||||
T_WATCH_ULTRA: _HardwareModel.ValueType # 114
|
||||
"""
|
||||
LilyGo T-Watch Ultra
|
||||
"""
|
||||
THINKNODE_M3: _HardwareModel.ValueType # 115
|
||||
"""
|
||||
Elecrow ThinkNode M3
|
||||
"""
|
||||
WISMESH_TAP_V2: _HardwareModel.ValueType # 116
|
||||
"""
|
||||
RAK WISMESH_TAP_V2 with ESP32-S3 CPU
|
||||
"""
|
||||
RAK3401: _HardwareModel.ValueType # 117
|
||||
"""
|
||||
RAK3401
|
||||
"""
|
||||
PRIVATE_HW: _HardwareModel.ValueType # 255
|
||||
"""
|
||||
------------------------------------------------------------------------------------------------------------------------------------------
|
||||
@@ -930,9 +959,9 @@ SEEED_WIO_TRACKER_L1_EINK: HardwareModel.ValueType # 100
|
||||
"""
|
||||
Seeed Tracker L1 EINK driver
|
||||
"""
|
||||
QWANTZ_TINY_ARMS: HardwareModel.ValueType # 101
|
||||
MUZI_R1_NEO: HardwareModel.ValueType # 101
|
||||
"""
|
||||
Reserved ID for future and past use
|
||||
Muzi Works R1 Neo
|
||||
"""
|
||||
T_DECK_PRO: HardwareModel.ValueType # 102
|
||||
"""
|
||||
@@ -942,9 +971,10 @@ T_LORA_PAGER: HardwareModel.ValueType # 103
|
||||
"""
|
||||
Lilygo TLora Pager
|
||||
"""
|
||||
GAT562_MESH_TRIAL_TRACKER: HardwareModel.ValueType # 104
|
||||
M5STACK_RESERVED: HardwareModel.ValueType # 104
|
||||
"""
|
||||
GAT562 Mesh Trial Tracker
|
||||
M5Stack Reserved
|
||||
0x68
|
||||
"""
|
||||
WISMESH_TAG: HardwareModel.ValueType # 105
|
||||
"""
|
||||
@@ -971,6 +1001,34 @@ HELTEC_V4: HardwareModel.ValueType # 110
|
||||
"""
|
||||
New Heltec LoRA32 with ESP32-S3 CPU
|
||||
"""
|
||||
M5STACK_C6L: HardwareModel.ValueType # 111
|
||||
"""
|
||||
M5Stack C6L
|
||||
"""
|
||||
M5STACK_CARDPUTER_ADV: HardwareModel.ValueType # 112
|
||||
"""
|
||||
M5Stack Cardputer Adv
|
||||
"""
|
||||
HELTEC_WIRELESS_TRACKER_V2: HardwareModel.ValueType # 113
|
||||
"""
|
||||
ESP32S3 main controller with GPS and TFT screen.
|
||||
"""
|
||||
T_WATCH_ULTRA: HardwareModel.ValueType # 114
|
||||
"""
|
||||
LilyGo T-Watch Ultra
|
||||
"""
|
||||
THINKNODE_M3: HardwareModel.ValueType # 115
|
||||
"""
|
||||
Elecrow ThinkNode M3
|
||||
"""
|
||||
WISMESH_TAP_V2: HardwareModel.ValueType # 116
|
||||
"""
|
||||
RAK WISMESH_TAP_V2 with ESP32-S3 CPU
|
||||
"""
|
||||
RAK3401: HardwareModel.ValueType # 117
|
||||
"""
|
||||
RAK3401
|
||||
"""
|
||||
PRIVATE_HW: HardwareModel.ValueType # 255
|
||||
"""
|
||||
------------------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -874,6 +874,7 @@ class ModuleConfig(google.protobuf.message.Message):
|
||||
HEALTH_MEASUREMENT_ENABLED_FIELD_NUMBER: builtins.int
|
||||
HEALTH_UPDATE_INTERVAL_FIELD_NUMBER: builtins.int
|
||||
HEALTH_SCREEN_ENABLED_FIELD_NUMBER: builtins.int
|
||||
DEVICE_TELEMETRY_ENABLED_FIELD_NUMBER: builtins.int
|
||||
device_update_interval: builtins.int
|
||||
"""
|
||||
Interval in seconds of how often we should try to send our
|
||||
@@ -934,6 +935,11 @@ class ModuleConfig(google.protobuf.message.Message):
|
||||
"""
|
||||
Enable/Disable the health telemetry module on-device display
|
||||
"""
|
||||
device_telemetry_enabled: builtins.bool
|
||||
"""
|
||||
Enable/Disable the device telemetry module to send metrics to the mesh
|
||||
Note: We will still send telemtry to the connected phone / client every minute over the API
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -950,8 +956,9 @@ class ModuleConfig(google.protobuf.message.Message):
|
||||
health_measurement_enabled: builtins.bool = ...,
|
||||
health_update_interval: builtins.int = ...,
|
||||
health_screen_enabled: builtins.bool = ...,
|
||||
device_telemetry_enabled: builtins.bool = ...,
|
||||
) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["air_quality_enabled", b"air_quality_enabled", "air_quality_interval", b"air_quality_interval", "device_update_interval", b"device_update_interval", "environment_display_fahrenheit", b"environment_display_fahrenheit", "environment_measurement_enabled", b"environment_measurement_enabled", "environment_screen_enabled", b"environment_screen_enabled", "environment_update_interval", b"environment_update_interval", "health_measurement_enabled", b"health_measurement_enabled", "health_screen_enabled", b"health_screen_enabled", "health_update_interval", b"health_update_interval", "power_measurement_enabled", b"power_measurement_enabled", "power_screen_enabled", b"power_screen_enabled", "power_update_interval", b"power_update_interval"]) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["air_quality_enabled", b"air_quality_enabled", "air_quality_interval", b"air_quality_interval", "device_telemetry_enabled", b"device_telemetry_enabled", "device_update_interval", b"device_update_interval", "environment_display_fahrenheit", b"environment_display_fahrenheit", "environment_measurement_enabled", b"environment_measurement_enabled", "environment_screen_enabled", b"environment_screen_enabled", "environment_update_interval", b"environment_update_interval", "health_measurement_enabled", b"health_measurement_enabled", "health_screen_enabled", b"health_screen_enabled", "health_update_interval", b"health_update_interval", "power_measurement_enabled", b"power_measurement_enabled", "power_screen_enabled", b"power_screen_enabled", "power_update_interval", b"power_update_interval"]) -> None: ...
|
||||
|
||||
@typing.final
|
||||
class CannedMessageConfig(google.protobuf.message.Message):
|
||||
|
||||
@@ -15,14 +15,14 @@ from meshtastic.protobuf import config_pb2 as meshtastic_dot_protobuf_dot_config
|
||||
from meshtastic.protobuf import mesh_pb2 as meshtastic_dot_protobuf_dot_mesh__pb2
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/mqtt.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"j\n\x0fServiceEnvelope\x12/\n\x06packet\x18\x01 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x12\n\nchannel_id\x18\x02 \x01(\t\x12\x12\n\ngateway_id\x18\x03 \x01(\t\"\x83\x04\n\tMapReport\x12\x11\n\tlong_name\x18\x01 \x01(\t\x12\x12\n\nshort_name\x18\x02 \x01(\t\x12;\n\x04role\x18\x03 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x18\n\x10\x66irmware_version\x18\x05 \x01(\t\x12\x41\n\x06region\x18\x06 \x01(\x0e\x32\x31.meshtastic.protobuf.Config.LoRaConfig.RegionCode\x12H\n\x0cmodem_preset\x18\x07 \x01(\x0e\x32\x32.meshtastic.protobuf.Config.LoRaConfig.ModemPreset\x12\x1b\n\x13has_default_channel\x18\x08 \x01(\x08\x12\x12\n\nlatitude_i\x18\t \x01(\x0f\x12\x13\n\x0blongitude_i\x18\n \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x0b \x01(\x05\x12\x1a\n\x12position_precision\x18\x0c \x01(\r\x12\x1e\n\x16num_online_local_nodes\x18\r \x01(\r\x12!\n\x19has_opted_report_location\x18\x0e \x01(\x08\x42_\n\x13\x63om.geeksville.meshB\nMQTTProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1emeshtastic/protobuf/mqtt.proto\x12\x13meshtastic.protobuf\x1a meshtastic/protobuf/config.proto\x1a\x1emeshtastic/protobuf/mesh.proto\"j\n\x0fServiceEnvelope\x12/\n\x06packet\x18\x01 \x01(\x0b\x32\x1f.meshtastic.protobuf.MeshPacket\x12\x12\n\nchannel_id\x18\x02 \x01(\t\x12\x12\n\ngateway_id\x18\x03 \x01(\t\"\x83\x04\n\tMapReport\x12\x11\n\tlong_name\x18\x01 \x01(\t\x12\x12\n\nshort_name\x18\x02 \x01(\t\x12;\n\x04role\x18\x03 \x01(\x0e\x32-.meshtastic.protobuf.Config.DeviceConfig.Role\x12\x34\n\x08hw_model\x18\x04 \x01(\x0e\x32\".meshtastic.protobuf.HardwareModel\x12\x18\n\x10\x66irmware_version\x18\x05 \x01(\t\x12\x41\n\x06region\x18\x06 \x01(\x0e\x32\x31.meshtastic.protobuf.Config.LoRaConfig.RegionCode\x12H\n\x0cmodem_preset\x18\x07 \x01(\x0e\x32\x32.meshtastic.protobuf.Config.LoRaConfig.ModemPreset\x12\x1b\n\x13has_default_channel\x18\x08 \x01(\x08\x12\x12\n\nlatitude_i\x18\t \x01(\x0f\x12\x13\n\x0blongitude_i\x18\n \x01(\x0f\x12\x10\n\x08\x61ltitude\x18\x0b \x01(\x05\x12\x1a\n\x12position_precision\x18\x0c \x01(\r\x12\x1e\n\x16num_online_local_nodes\x18\r \x01(\r\x12!\n\x19has_opted_report_location\x18\x0e \x01(\x08\x42`\n\x14org.meshtastic.protoB\nMQTTProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.mqtt_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\nMQTTProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\nMQTTProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_SERVICEENVELOPE']._serialized_start=121
|
||||
_globals['_SERVICEENVELOPE']._serialized_end=227
|
||||
_globals['_MAPREPORT']._serialized_start=230
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/paxcount.proto\x12\x13meshtastic.protobuf\"5\n\x08Paxcount\x12\x0c\n\x04wifi\x18\x01 \x01(\r\x12\x0b\n\x03\x62le\x18\x02 \x01(\r\x12\x0e\n\x06uptime\x18\x03 \x01(\rBc\n\x13\x63om.geeksville.meshB\x0ePaxcountProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/paxcount.proto\x12\x13meshtastic.protobuf\"5\n\x08Paxcount\x12\x0c\n\x04wifi\x18\x01 \x01(\r\x12\x0b\n\x03\x62le\x18\x02 \x01(\r\x12\x0e\n\x06uptime\x18\x03 \x01(\rBd\n\x14org.meshtastic.protoB\x0ePaxcountProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.paxcount_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016PaxcountProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016PaxcountProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_PAXCOUNT']._serialized_start=59
|
||||
_globals['_PAXCOUNT']._serialized_end=112
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/portnums.proto\x12\x13meshtastic.protobuf*\xf6\x04\n\x07PortNum\x12\x0f\n\x0bUNKNOWN_APP\x10\x00\x12\x14\n\x10TEXT_MESSAGE_APP\x10\x01\x12\x17\n\x13REMOTE_HARDWARE_APP\x10\x02\x12\x10\n\x0cPOSITION_APP\x10\x03\x12\x10\n\x0cNODEINFO_APP\x10\x04\x12\x0f\n\x0bROUTING_APP\x10\x05\x12\r\n\tADMIN_APP\x10\x06\x12\x1f\n\x1bTEXT_MESSAGE_COMPRESSED_APP\x10\x07\x12\x10\n\x0cWAYPOINT_APP\x10\x08\x12\r\n\tAUDIO_APP\x10\t\x12\x18\n\x14\x44\x45TECTION_SENSOR_APP\x10\n\x12\r\n\tALERT_APP\x10\x0b\x12\x18\n\x14KEY_VERIFICATION_APP\x10\x0c\x12\r\n\tREPLY_APP\x10 \x12\x11\n\rIP_TUNNEL_APP\x10!\x12\x12\n\x0ePAXCOUNTER_APP\x10\"\x12\x0e\n\nSERIAL_APP\x10@\x12\x15\n\x11STORE_FORWARD_APP\x10\x41\x12\x12\n\x0eRANGE_TEST_APP\x10\x42\x12\x11\n\rTELEMETRY_APP\x10\x43\x12\x0b\n\x07ZPS_APP\x10\x44\x12\x11\n\rSIMULATOR_APP\x10\x45\x12\x12\n\x0eTRACEROUTE_APP\x10\x46\x12\x14\n\x10NEIGHBORINFO_APP\x10G\x12\x0f\n\x0b\x41TAK_PLUGIN\x10H\x12\x12\n\x0eMAP_REPORT_APP\x10I\x12\x13\n\x0fPOWERSTRESS_APP\x10J\x12\x18\n\x14RETICULUM_TUNNEL_APP\x10L\x12\x0f\n\x0b\x43\x41YENNE_APP\x10M\x12\x10\n\x0bPRIVATE_APP\x10\x80\x02\x12\x13\n\x0e\x41TAK_FORWARDER\x10\x81\x02\x12\x08\n\x03MAX\x10\xff\x03\x42]\n\x13\x63om.geeksville.meshB\x08PortnumsZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/portnums.proto\x12\x13meshtastic.protobuf*\xf6\x04\n\x07PortNum\x12\x0f\n\x0bUNKNOWN_APP\x10\x00\x12\x14\n\x10TEXT_MESSAGE_APP\x10\x01\x12\x17\n\x13REMOTE_HARDWARE_APP\x10\x02\x12\x10\n\x0cPOSITION_APP\x10\x03\x12\x10\n\x0cNODEINFO_APP\x10\x04\x12\x0f\n\x0bROUTING_APP\x10\x05\x12\r\n\tADMIN_APP\x10\x06\x12\x1f\n\x1bTEXT_MESSAGE_COMPRESSED_APP\x10\x07\x12\x10\n\x0cWAYPOINT_APP\x10\x08\x12\r\n\tAUDIO_APP\x10\t\x12\x18\n\x14\x44\x45TECTION_SENSOR_APP\x10\n\x12\r\n\tALERT_APP\x10\x0b\x12\x18\n\x14KEY_VERIFICATION_APP\x10\x0c\x12\r\n\tREPLY_APP\x10 \x12\x11\n\rIP_TUNNEL_APP\x10!\x12\x12\n\x0ePAXCOUNTER_APP\x10\"\x12\x0e\n\nSERIAL_APP\x10@\x12\x15\n\x11STORE_FORWARD_APP\x10\x41\x12\x12\n\x0eRANGE_TEST_APP\x10\x42\x12\x11\n\rTELEMETRY_APP\x10\x43\x12\x0b\n\x07ZPS_APP\x10\x44\x12\x11\n\rSIMULATOR_APP\x10\x45\x12\x12\n\x0eTRACEROUTE_APP\x10\x46\x12\x14\n\x10NEIGHBORINFO_APP\x10G\x12\x0f\n\x0b\x41TAK_PLUGIN\x10H\x12\x12\n\x0eMAP_REPORT_APP\x10I\x12\x13\n\x0fPOWERSTRESS_APP\x10J\x12\x18\n\x14RETICULUM_TUNNEL_APP\x10L\x12\x0f\n\x0b\x43\x41YENNE_APP\x10M\x12\x10\n\x0bPRIVATE_APP\x10\x80\x02\x12\x13\n\x0e\x41TAK_FORWARDER\x10\x81\x02\x12\x08\n\x03MAX\x10\xff\x03\x42^\n\x14org.meshtastic.protoB\x08PortnumsZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.portnums_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\010PortnumsZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\010PortnumsZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_PORTNUM']._serialized_start=60
|
||||
_globals['_PORTNUM']._serialized_end=690
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/powermon.proto\x12\x13meshtastic.protobuf\"\xe0\x01\n\x08PowerMon\"\xd3\x01\n\x05State\x12\x08\n\x04None\x10\x00\x12\x11\n\rCPU_DeepSleep\x10\x01\x12\x12\n\x0e\x43PU_LightSleep\x10\x02\x12\x0c\n\x08Vext1_On\x10\x04\x12\r\n\tLora_RXOn\x10\x08\x12\r\n\tLora_TXOn\x10\x10\x12\x11\n\rLora_RXActive\x10 \x12\t\n\x05\x42T_On\x10@\x12\x0b\n\x06LED_On\x10\x80\x01\x12\x0e\n\tScreen_On\x10\x80\x02\x12\x13\n\x0eScreen_Drawing\x10\x80\x04\x12\x0c\n\x07Wifi_On\x10\x80\x08\x12\x0f\n\nGPS_Active\x10\x80\x10\"\x88\x03\n\x12PowerStressMessage\x12;\n\x03\x63md\x18\x01 \x01(\x0e\x32..meshtastic.protobuf.PowerStressMessage.Opcode\x12\x13\n\x0bnum_seconds\x18\x02 \x01(\x02\"\x9f\x02\n\x06Opcode\x12\t\n\x05UNSET\x10\x00\x12\x0e\n\nPRINT_INFO\x10\x01\x12\x0f\n\x0b\x46ORCE_QUIET\x10\x02\x12\r\n\tEND_QUIET\x10\x03\x12\r\n\tSCREEN_ON\x10\x10\x12\x0e\n\nSCREEN_OFF\x10\x11\x12\x0c\n\x08\x43PU_IDLE\x10 \x12\x11\n\rCPU_DEEPSLEEP\x10!\x12\x0e\n\nCPU_FULLON\x10\"\x12\n\n\x06LED_ON\x10\x30\x12\x0b\n\x07LED_OFF\x10\x31\x12\x0c\n\x08LORA_OFF\x10@\x12\x0b\n\x07LORA_TX\x10\x41\x12\x0b\n\x07LORA_RX\x10\x42\x12\n\n\x06\x42T_OFF\x10P\x12\t\n\x05\x42T_ON\x10Q\x12\x0c\n\x08WIFI_OFF\x10`\x12\x0b\n\x07WIFI_ON\x10\x61\x12\x0b\n\x07GPS_OFF\x10p\x12\n\n\x06GPS_ON\x10qBc\n\x13\x63om.geeksville.meshB\x0ePowerMonProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\"meshtastic/protobuf/powermon.proto\x12\x13meshtastic.protobuf\"\xe0\x01\n\x08PowerMon\"\xd3\x01\n\x05State\x12\x08\n\x04None\x10\x00\x12\x11\n\rCPU_DeepSleep\x10\x01\x12\x12\n\x0e\x43PU_LightSleep\x10\x02\x12\x0c\n\x08Vext1_On\x10\x04\x12\r\n\tLora_RXOn\x10\x08\x12\r\n\tLora_TXOn\x10\x10\x12\x11\n\rLora_RXActive\x10 \x12\t\n\x05\x42T_On\x10@\x12\x0b\n\x06LED_On\x10\x80\x01\x12\x0e\n\tScreen_On\x10\x80\x02\x12\x13\n\x0eScreen_Drawing\x10\x80\x04\x12\x0c\n\x07Wifi_On\x10\x80\x08\x12\x0f\n\nGPS_Active\x10\x80\x10\"\x88\x03\n\x12PowerStressMessage\x12;\n\x03\x63md\x18\x01 \x01(\x0e\x32..meshtastic.protobuf.PowerStressMessage.Opcode\x12\x13\n\x0bnum_seconds\x18\x02 \x01(\x02\"\x9f\x02\n\x06Opcode\x12\t\n\x05UNSET\x10\x00\x12\x0e\n\nPRINT_INFO\x10\x01\x12\x0f\n\x0b\x46ORCE_QUIET\x10\x02\x12\r\n\tEND_QUIET\x10\x03\x12\r\n\tSCREEN_ON\x10\x10\x12\x0e\n\nSCREEN_OFF\x10\x11\x12\x0c\n\x08\x43PU_IDLE\x10 \x12\x11\n\rCPU_DEEPSLEEP\x10!\x12\x0e\n\nCPU_FULLON\x10\"\x12\n\n\x06LED_ON\x10\x30\x12\x0b\n\x07LED_OFF\x10\x31\x12\x0c\n\x08LORA_OFF\x10@\x12\x0b\n\x07LORA_TX\x10\x41\x12\x0b\n\x07LORA_RX\x10\x42\x12\n\n\x06\x42T_OFF\x10P\x12\t\n\x05\x42T_ON\x10Q\x12\x0c\n\x08WIFI_OFF\x10`\x12\x0b\n\x07WIFI_ON\x10\x61\x12\x0b\n\x07GPS_OFF\x10p\x12\n\n\x06GPS_ON\x10qBd\n\x14org.meshtastic.protoB\x0ePowerMonProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.powermon_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016PowerMonProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016PowerMonProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_POWERMON']._serialized_start=60
|
||||
_globals['_POWERMON']._serialized_end=284
|
||||
_globals['_POWERMON_STATE']._serialized_start=73
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)meshtastic/protobuf/remote_hardware.proto\x12\x13meshtastic.protobuf\"\xdf\x01\n\x0fHardwareMessage\x12\x37\n\x04type\x18\x01 \x01(\x0e\x32).meshtastic.protobuf.HardwareMessage.Type\x12\x11\n\tgpio_mask\x18\x02 \x01(\x04\x12\x12\n\ngpio_value\x18\x03 \x01(\x04\"l\n\x04Type\x12\t\n\x05UNSET\x10\x00\x12\x0f\n\x0bWRITE_GPIOS\x10\x01\x12\x0f\n\x0bWATCH_GPIOS\x10\x02\x12\x11\n\rGPIOS_CHANGED\x10\x03\x12\x0e\n\nREAD_GPIOS\x10\x04\x12\x14\n\x10READ_GPIOS_REPLY\x10\x05\x42\x63\n\x13\x63om.geeksville.meshB\x0eRemoteHardwareZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n)meshtastic/protobuf/remote_hardware.proto\x12\x13meshtastic.protobuf\"\xdf\x01\n\x0fHardwareMessage\x12\x37\n\x04type\x18\x01 \x01(\x0e\x32).meshtastic.protobuf.HardwareMessage.Type\x12\x11\n\tgpio_mask\x18\x02 \x01(\x04\x12\x12\n\ngpio_value\x18\x03 \x01(\x04\"l\n\x04Type\x12\t\n\x05UNSET\x10\x00\x12\x0f\n\x0bWRITE_GPIOS\x10\x01\x12\x0f\n\x0bWATCH_GPIOS\x10\x02\x12\x11\n\rGPIOS_CHANGED\x10\x03\x12\x0e\n\nREAD_GPIOS\x10\x04\x12\x14\n\x10READ_GPIOS_REPLY\x10\x05\x42\x64\n\x14org.meshtastic.protoB\x0eRemoteHardwareZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.remote_hardware_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\016RemoteHardwareZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\016RemoteHardwareZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_HARDWAREMESSAGE']._serialized_start=67
|
||||
_globals['_HARDWAREMESSAGE']._serialized_end=290
|
||||
_globals['_HARDWAREMESSAGE_TYPE']._serialized_start=182
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fmeshtastic/protobuf/rtttl.proto\x12\x13meshtastic.protobuf\"\x1f\n\x0bRTTTLConfig\x12\x10\n\x08ringtone\x18\x01 \x01(\tBf\n\x13\x63om.geeksville.meshB\x11RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1fmeshtastic/protobuf/rtttl.proto\x12\x13meshtastic.protobuf\"\x1f\n\x0bRTTTLConfig\x12\x10\n\x08ringtone\x18\x01 \x01(\tBg\n\x14org.meshtastic.protoB\x11RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.rtttl_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\021RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\021RTTTLConfigProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_RTTTLCONFIG']._serialized_start=56
|
||||
_globals['_RTTTLCONFIG']._serialized_end=87
|
||||
# @@protoc_insertion_point(module_scope)
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&meshtastic/protobuf/storeforward.proto\x12\x13meshtastic.protobuf\"\xc0\x07\n\x0fStoreAndForward\x12@\n\x02rr\x18\x01 \x01(\x0e\x32\x34.meshtastic.protobuf.StoreAndForward.RequestResponse\x12@\n\x05stats\x18\x02 \x01(\x0b\x32/.meshtastic.protobuf.StoreAndForward.StatisticsH\x00\x12?\n\x07history\x18\x03 \x01(\x0b\x32,.meshtastic.protobuf.StoreAndForward.HistoryH\x00\x12\x43\n\theartbeat\x18\x04 \x01(\x0b\x32..meshtastic.protobuf.StoreAndForward.HeartbeatH\x00\x12\x0e\n\x04text\x18\x05 \x01(\x0cH\x00\x1a\xcd\x01\n\nStatistics\x12\x16\n\x0emessages_total\x18\x01 \x01(\r\x12\x16\n\x0emessages_saved\x18\x02 \x01(\r\x12\x14\n\x0cmessages_max\x18\x03 \x01(\r\x12\x0f\n\x07up_time\x18\x04 \x01(\r\x12\x10\n\x08requests\x18\x05 \x01(\r\x12\x18\n\x10requests_history\x18\x06 \x01(\r\x12\x11\n\theartbeat\x18\x07 \x01(\x08\x12\x12\n\nreturn_max\x18\x08 \x01(\r\x12\x15\n\rreturn_window\x18\t \x01(\r\x1aI\n\x07History\x12\x18\n\x10history_messages\x18\x01 \x01(\r\x12\x0e\n\x06window\x18\x02 \x01(\r\x12\x14\n\x0clast_request\x18\x03 \x01(\r\x1a.\n\tHeartbeat\x12\x0e\n\x06period\x18\x01 \x01(\r\x12\x11\n\tsecondary\x18\x02 \x01(\r\"\xbc\x02\n\x0fRequestResponse\x12\t\n\x05UNSET\x10\x00\x12\x10\n\x0cROUTER_ERROR\x10\x01\x12\x14\n\x10ROUTER_HEARTBEAT\x10\x02\x12\x0f\n\x0bROUTER_PING\x10\x03\x12\x0f\n\x0bROUTER_PONG\x10\x04\x12\x0f\n\x0bROUTER_BUSY\x10\x05\x12\x12\n\x0eROUTER_HISTORY\x10\x06\x12\x10\n\x0cROUTER_STATS\x10\x07\x12\x16\n\x12ROUTER_TEXT_DIRECT\x10\x08\x12\x19\n\x15ROUTER_TEXT_BROADCAST\x10\t\x12\x10\n\x0c\x43LIENT_ERROR\x10@\x12\x12\n\x0e\x43LIENT_HISTORY\x10\x41\x12\x10\n\x0c\x43LIENT_STATS\x10\x42\x12\x0f\n\x0b\x43LIENT_PING\x10\x43\x12\x0f\n\x0b\x43LIENT_PONG\x10\x44\x12\x10\n\x0c\x43LIENT_ABORT\x10jB\t\n\x07variantBj\n\x13\x63om.geeksville.meshB\x15StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n&meshtastic/protobuf/storeforward.proto\x12\x13meshtastic.protobuf\"\xc0\x07\n\x0fStoreAndForward\x12@\n\x02rr\x18\x01 \x01(\x0e\x32\x34.meshtastic.protobuf.StoreAndForward.RequestResponse\x12@\n\x05stats\x18\x02 \x01(\x0b\x32/.meshtastic.protobuf.StoreAndForward.StatisticsH\x00\x12?\n\x07history\x18\x03 \x01(\x0b\x32,.meshtastic.protobuf.StoreAndForward.HistoryH\x00\x12\x43\n\theartbeat\x18\x04 \x01(\x0b\x32..meshtastic.protobuf.StoreAndForward.HeartbeatH\x00\x12\x0e\n\x04text\x18\x05 \x01(\x0cH\x00\x1a\xcd\x01\n\nStatistics\x12\x16\n\x0emessages_total\x18\x01 \x01(\r\x12\x16\n\x0emessages_saved\x18\x02 \x01(\r\x12\x14\n\x0cmessages_max\x18\x03 \x01(\r\x12\x0f\n\x07up_time\x18\x04 \x01(\r\x12\x10\n\x08requests\x18\x05 \x01(\r\x12\x18\n\x10requests_history\x18\x06 \x01(\r\x12\x11\n\theartbeat\x18\x07 \x01(\x08\x12\x12\n\nreturn_max\x18\x08 \x01(\r\x12\x15\n\rreturn_window\x18\t \x01(\r\x1aI\n\x07History\x12\x18\n\x10history_messages\x18\x01 \x01(\r\x12\x0e\n\x06window\x18\x02 \x01(\r\x12\x14\n\x0clast_request\x18\x03 \x01(\r\x1a.\n\tHeartbeat\x12\x0e\n\x06period\x18\x01 \x01(\r\x12\x11\n\tsecondary\x18\x02 \x01(\r\"\xbc\x02\n\x0fRequestResponse\x12\t\n\x05UNSET\x10\x00\x12\x10\n\x0cROUTER_ERROR\x10\x01\x12\x14\n\x10ROUTER_HEARTBEAT\x10\x02\x12\x0f\n\x0bROUTER_PING\x10\x03\x12\x0f\n\x0bROUTER_PONG\x10\x04\x12\x0f\n\x0bROUTER_BUSY\x10\x05\x12\x12\n\x0eROUTER_HISTORY\x10\x06\x12\x10\n\x0cROUTER_STATS\x10\x07\x12\x16\n\x12ROUTER_TEXT_DIRECT\x10\x08\x12\x19\n\x15ROUTER_TEXT_BROADCAST\x10\t\x12\x10\n\x0c\x43LIENT_ERROR\x10@\x12\x12\n\x0e\x43LIENT_HISTORY\x10\x41\x12\x10\n\x0c\x43LIENT_STATS\x10\x42\x12\x0f\n\x0b\x43LIENT_PING\x10\x43\x12\x0f\n\x0b\x43LIENT_PONG\x10\x44\x12\x10\n\x0c\x43LIENT_ABORT\x10jB\t\n\x07variantBk\n\x14org.meshtastic.protoB\x15StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.storeforward_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\025StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\025StoreAndForwardProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_STOREANDFORWARD']._serialized_start=64
|
||||
_globals['_STOREANDFORWARD']._serialized_end=1024
|
||||
_globals['_STOREANDFORWARD_STATISTICS']._serialized_start=366
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -203,6 +203,10 @@ class _TelemetrySensorTypeEnumTypeWrapper(google.protobuf.internal.enum_type_wra
|
||||
"""
|
||||
TSL2561 light sensor
|
||||
"""
|
||||
BH1750: _TelemetrySensorType.ValueType # 45
|
||||
"""
|
||||
BH1750 light sensor
|
||||
"""
|
||||
|
||||
class TelemetrySensorType(_TelemetrySensorType, metaclass=_TelemetrySensorTypeEnumTypeWrapper):
|
||||
"""
|
||||
@@ -389,6 +393,10 @@ TSL2561: TelemetrySensorType.ValueType # 44
|
||||
"""
|
||||
TSL2561 light sensor
|
||||
"""
|
||||
BH1750: TelemetrySensorType.ValueType # 45
|
||||
"""
|
||||
BH1750 light sensor
|
||||
"""
|
||||
global___TelemetrySensorType = TelemetrySensorType
|
||||
|
||||
@typing.final
|
||||
@@ -1026,6 +1034,7 @@ class LocalStats(google.protobuf.message.Message):
|
||||
NUM_TX_RELAY_CANCELED_FIELD_NUMBER: builtins.int
|
||||
HEAP_TOTAL_BYTES_FIELD_NUMBER: builtins.int
|
||||
HEAP_FREE_BYTES_FIELD_NUMBER: builtins.int
|
||||
NUM_TX_DROPPED_FIELD_NUMBER: builtins.int
|
||||
uptime_seconds: builtins.int
|
||||
"""
|
||||
How long the device has been running since the last reboot (in seconds)
|
||||
@@ -1080,6 +1089,10 @@ class LocalStats(google.protobuf.message.Message):
|
||||
"""
|
||||
Number of bytes free in the heap
|
||||
"""
|
||||
num_tx_dropped: builtins.int
|
||||
"""
|
||||
Number of packets that were dropped because the transmit queue was full.
|
||||
"""
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
@@ -1096,8 +1109,9 @@ class LocalStats(google.protobuf.message.Message):
|
||||
num_tx_relay_canceled: builtins.int = ...,
|
||||
heap_total_bytes: builtins.int = ...,
|
||||
heap_free_bytes: builtins.int = ...,
|
||||
num_tx_dropped: builtins.int = ...,
|
||||
) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["air_util_tx", b"air_util_tx", "channel_utilization", b"channel_utilization", "heap_free_bytes", b"heap_free_bytes", "heap_total_bytes", b"heap_total_bytes", "num_online_nodes", b"num_online_nodes", "num_packets_rx", b"num_packets_rx", "num_packets_rx_bad", b"num_packets_rx_bad", "num_packets_tx", b"num_packets_tx", "num_rx_dupe", b"num_rx_dupe", "num_total_nodes", b"num_total_nodes", "num_tx_relay", b"num_tx_relay", "num_tx_relay_canceled", b"num_tx_relay_canceled", "uptime_seconds", b"uptime_seconds"]) -> None: ...
|
||||
def ClearField(self, field_name: typing.Literal["air_util_tx", b"air_util_tx", "channel_utilization", b"channel_utilization", "heap_free_bytes", b"heap_free_bytes", "heap_total_bytes", b"heap_total_bytes", "num_online_nodes", b"num_online_nodes", "num_packets_rx", b"num_packets_rx", "num_packets_rx_bad", b"num_packets_rx_bad", "num_packets_tx", b"num_packets_tx", "num_rx_dupe", b"num_rx_dupe", "num_total_nodes", b"num_total_nodes", "num_tx_dropped", b"num_tx_dropped", "num_tx_relay", b"num_tx_relay", "num_tx_relay_canceled", b"num_tx_relay_canceled", "uptime_seconds", b"uptime_seconds"]) -> None: ...
|
||||
|
||||
global___LocalStats = LocalStats
|
||||
|
||||
|
||||
@@ -13,14 +13,14 @@ _sym_db = _symbol_database.Default()
|
||||
|
||||
|
||||
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n meshtastic/protobuf/xmodem.proto\x12\x13meshtastic.protobuf\"\xbf\x01\n\x06XModem\x12\x34\n\x07\x63ontrol\x18\x01 \x01(\x0e\x32#.meshtastic.protobuf.XModem.Control\x12\x0b\n\x03seq\x18\x02 \x01(\r\x12\r\n\x05\x63rc16\x18\x03 \x01(\r\x12\x0e\n\x06\x62uffer\x18\x04 \x01(\x0c\"S\n\x07\x43ontrol\x12\x07\n\x03NUL\x10\x00\x12\x07\n\x03SOH\x10\x01\x12\x07\n\x03STX\x10\x02\x12\x07\n\x03\x45OT\x10\x04\x12\x07\n\x03\x41\x43K\x10\x06\x12\x07\n\x03NAK\x10\x15\x12\x07\n\x03\x43\x41N\x10\x18\x12\t\n\x05\x43TRLZ\x10\x1a\x42\x61\n\x13\x63om.geeksville.meshB\x0cXmodemProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n meshtastic/protobuf/xmodem.proto\x12\x13meshtastic.protobuf\"\xbf\x01\n\x06XModem\x12\x34\n\x07\x63ontrol\x18\x01 \x01(\x0e\x32#.meshtastic.protobuf.XModem.Control\x12\x0b\n\x03seq\x18\x02 \x01(\r\x12\r\n\x05\x63rc16\x18\x03 \x01(\r\x12\x0e\n\x06\x62uffer\x18\x04 \x01(\x0c\"S\n\x07\x43ontrol\x12\x07\n\x03NUL\x10\x00\x12\x07\n\x03SOH\x10\x01\x12\x07\n\x03STX\x10\x02\x12\x07\n\x03\x45OT\x10\x04\x12\x07\n\x03\x41\x43K\x10\x06\x12\x07\n\x03NAK\x10\x15\x12\x07\n\x03\x43\x41N\x10\x18\x12\t\n\x05\x43TRLZ\x10\x1a\x42\x62\n\x14org.meshtastic.protoB\x0cXmodemProtosZ\"github.com/meshtastic/go/generated\xaa\x02\x14Meshtastic.Protobufs\xba\x02\x00\x62\x06proto3')
|
||||
|
||||
_globals = globals()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'meshtastic.protobuf.xmodem_pb2', _globals)
|
||||
if _descriptor._USE_C_DESCRIPTORS == False:
|
||||
DESCRIPTOR._options = None
|
||||
DESCRIPTOR._serialized_options = b'\n\023com.geeksville.meshB\014XmodemProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
DESCRIPTOR._serialized_options = b'\n\024org.meshtastic.protoB\014XmodemProtosZ\"github.com/meshtastic/go/generated\252\002\024Meshtastic.Protobufs\272\002\000'
|
||||
_globals['_XMODEM']._serialized_start=58
|
||||
_globals['_XMODEM']._serialized_end=249
|
||||
_globals['_XMODEM_CONTROL']._serialized_start=166
|
||||
|
||||
57
meshview/__version__.py
Normal file
57
meshview/__version__.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Version information for MeshView."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
__version__ = "3.0.1"
|
||||
__release_date__ = "2025-12-4"
|
||||
|
||||
|
||||
def get_git_revision():
|
||||
"""Get the current git revision hash."""
|
||||
try:
|
||||
repo_dir = Path(__file__).parent.parent
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=repo_dir,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_git_revision_short():
|
||||
"""Get the short git revision hash."""
|
||||
try:
|
||||
repo_dir = Path(__file__).parent.parent
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
cwd=repo_dir,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_version_info():
|
||||
"""Get complete version information."""
|
||||
return {
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
"git_revision": get_git_revision(),
|
||||
"git_revision_short": get_git_revision_short(),
|
||||
}
|
||||
|
||||
|
||||
# Cache git info at import time for performance
|
||||
_git_revision = get_git_revision()
|
||||
_git_revision_short = get_git_revision_short()
|
||||
|
||||
# Full version string for display
|
||||
__version_string__ = f"{__version__} ~ {__release_date__}"
|
||||
@@ -1,110 +1,212 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
{
|
||||
"base": {
|
||||
"chat": "Chat",
|
||||
"nodes": "Nodes",
|
||||
"everything": "See Everything",
|
||||
"graphs": "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": {
|
||||
"chat_title": "Chats:",
|
||||
"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": "All HW Models",
|
||||
"all_firmware": "All Firmware",
|
||||
|
||||
"show_favorites": "⭐ Show Favorites",
|
||||
"show_all": "⭐ Show All",
|
||||
"export_csv": "Export CSV",
|
||||
"clear_filters": "Clear Filters",
|
||||
|
||||
"showing_nodes": "Showing",
|
||||
"nodes_suffix": "nodes",
|
||||
|
||||
"loading_nodes": "Loading nodes...",
|
||||
"error_loading_nodes": "Error loading nodes",
|
||||
"no_nodes_found": "No nodes found",
|
||||
|
||||
"short_name": "Short",
|
||||
"long_name": "Long Name",
|
||||
"hw_model": "HW Model",
|
||||
"firmware": "Firmware",
|
||||
"role": "Role",
|
||||
"last_lat": "Last Latitude",
|
||||
"last_long": "Last Longitude",
|
||||
"channel": "Channel",
|
||||
"last_seen": "Last Seen",
|
||||
"favorite": "Favorite",
|
||||
|
||||
"time_just_now": "just now",
|
||||
"time_min_ago": "min ago",
|
||||
"time_hr_ago": "hr ago",
|
||||
"time_day_ago": "day ago",
|
||||
"time_days_ago": "days ago"
|
||||
},
|
||||
"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!"
|
||||
|
||||
"net": {
|
||||
"net_title": "Weekly Net:",
|
||||
"total_messages": "Number of messages:",
|
||||
"view_packet_details": "More details"
|
||||
},
|
||||
|
||||
"map": {
|
||||
"show_routers_only": "Show Routers Only",
|
||||
"share_view": "Share This View",
|
||||
"reset_filters": "Reset Filters To Defaults",
|
||||
"channel_label": "Channel:",
|
||||
"model_label": "Model:",
|
||||
"role_label": "Role:",
|
||||
"last_seen": "Last seen:",
|
||||
"firmware": "Firmware:",
|
||||
"link_copied": "Link Copied!",
|
||||
"legend_traceroute": "Traceroute (with arrows)",
|
||||
"legend_neighbor": "Neighbor"
|
||||
|
||||
},
|
||||
|
||||
"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",
|
||||
"node_id": "Node ID"
|
||||
},
|
||||
"top": {
|
||||
"top_traffic_nodes": "Top Nodes Traffic",
|
||||
"channel": "Channel",
|
||||
"search": "Search",
|
||||
"search_placeholder": "Search nodes...",
|
||||
"long_name": "Long Name",
|
||||
"short_name": "Short Name",
|
||||
"packets_sent": "Sent (24h)",
|
||||
"times_seen": "Seen (24h)",
|
||||
"avg_gateways": "Avg Gateways",
|
||||
"showing_nodes": "Showing",
|
||||
"nodes_suffix": "nodes"
|
||||
},
|
||||
|
||||
"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!"
|
||||
},
|
||||
"firehose":
|
||||
{
|
||||
"live_feed": "📡 Live Feed",
|
||||
"pause": "Pause",
|
||||
"resume": "Resume",
|
||||
"time": "Time",
|
||||
"packet_id": "Packet ID",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"port": "Port",
|
||||
"links": "Links",
|
||||
"unknown_app": "UNKNOWN APP",
|
||||
"text_message": "Text Message",
|
||||
"position": "Position",
|
||||
"node_info": "Node Info",
|
||||
"routing": "Routing",
|
||||
"administration": "Administration",
|
||||
"waypoint": "Waypoint",
|
||||
"store_forward": "Store Forward",
|
||||
"telemetry": "Telemetry",
|
||||
"trace_route": "Trace Route",
|
||||
"neighbor_info": "Neighbor Info",
|
||||
"direct_to_mqtt": "direct to MQTT",
|
||||
"all": "All",
|
||||
"map": "Map",
|
||||
"graph": "Graph"
|
||||
},
|
||||
"node": {
|
||||
"specifications": "Specifications",
|
||||
"node_id": "Node ID",
|
||||
"long_name": "Long Name",
|
||||
"short_name": "Short Name",
|
||||
"hw_model": "Hardware Model",
|
||||
"firmware": "Firmware",
|
||||
"role": "Role",
|
||||
"channel": "Channel",
|
||||
"latitude": "Latitude",
|
||||
"longitude": "Longitude",
|
||||
"last_update": "Last Update",
|
||||
"battery_voltage": "Battery & Voltage",
|
||||
"air_channel": "Air & Channel Utilization",
|
||||
"environment": "Environment Metrics",
|
||||
"neighbors_chart": "Neighbors (Signal-to-Noise)",
|
||||
"expand": "Expand",
|
||||
"export_csv": "Export CSV",
|
||||
"time": "Time",
|
||||
"packet_id": "Packet ID",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"port": "Port",
|
||||
"direct_to_mqtt": "Direct to MQTT",
|
||||
"all_broadcast": "All",
|
||||
"statistics": "Statistics",
|
||||
"last_24h": "24h",
|
||||
"packets_sent": "Packets sent",
|
||||
"times_seen": "Times seen"
|
||||
},
|
||||
"packet": {
|
||||
"loading": "Loading packet information...",
|
||||
"packet_id_label": "Packet ID",
|
||||
"from_node": "From Node",
|
||||
"to_node": "To Node",
|
||||
"channel": "Channel",
|
||||
"port": "Port",
|
||||
"raw_payload": "Raw Payload",
|
||||
"decoded_telemetry": "Decoded Telemetry",
|
||||
"location": "Location",
|
||||
"seen_by": "Seen By",
|
||||
"gateway": "Gateway",
|
||||
"rssi": "RSSI",
|
||||
"snr": "SNR",
|
||||
"hops": "Hop",
|
||||
"time": "Time",
|
||||
"packet_source": "Packet Source",
|
||||
"distance": "Distance",
|
||||
"node_id_short": "Node ID",
|
||||
"all_broadcast": "All",
|
||||
"direct_to_mqtt": "Direct to MQTT"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"base": {
|
||||
"conversations": "Conversaciones",
|
||||
"chat": "Conversaciones",
|
||||
"nodes": "Nodos",
|
||||
"everything": "Mostrar Todo",
|
||||
"graph": "Gráficos de la Malla",
|
||||
"everything": "Mostrar todo",
|
||||
"graphs": "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",
|
||||
"node_id": "ID de Nodo",
|
||||
"go_to_node": "Ir al nodo",
|
||||
"all": "Todos",
|
||||
"portnum_options": {
|
||||
"1": "Mensaje de Texto",
|
||||
@@ -21,48 +21,65 @@
|
||||
"71": "Información de Vecinos"
|
||||
}
|
||||
},
|
||||
|
||||
"chat": {
|
||||
"replying_to": "Respondiendo a:",
|
||||
"view_packet_details": "Ver detalles del paquete"
|
||||
"chat_title": "Conversaciones:",
|
||||
"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",
|
||||
"all_roles": "Todos los roles",
|
||||
"all_channels": "Todos los canales",
|
||||
"all_hw": "Todos los modelos",
|
||||
"all_firmware": "Todo el firmware",
|
||||
"show_favorites": "⭐ Mostrar favoritos",
|
||||
"show_all": "⭐ Mostrar todos",
|
||||
"export_csv": "Exportar CSV",
|
||||
"clear_filters": "Limpiar Filtros",
|
||||
"showing": "Mostrando",
|
||||
"nodes": "nodos",
|
||||
"short": "Corto",
|
||||
"long_name": "Largo",
|
||||
"hw_model": "Modelo",
|
||||
"clear_filters": "Limpiar filtros",
|
||||
"showing_nodes": "Mostrando",
|
||||
"nodes_suffix": "nodos",
|
||||
"loading_nodes": "Cargando nodos...",
|
||||
"error_loading_nodes": "Error al cargar nodos",
|
||||
"no_nodes_found": "No se encontraron nodos",
|
||||
"short_name": "Corto",
|
||||
"long_name": "Nombre largo",
|
||||
"hw_model": "Modelo HW",
|
||||
"firmware": "Firmware",
|
||||
"role": "Rol",
|
||||
"last_lat": "Última Latitud",
|
||||
"last_long": "Última Longitud",
|
||||
"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"
|
||||
"last_seen": "Última vez visto",
|
||||
"favorite": "Favorito",
|
||||
"time_just_now": "justo ahora",
|
||||
"time_min_ago": "min atrás",
|
||||
"time_hr_ago": "h atrás",
|
||||
"time_day_ago": "día atrás",
|
||||
"time_days_ago": "días atrás"
|
||||
},
|
||||
|
||||
"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."
|
||||
"net_title": "Red Semanal:",
|
||||
"total_messages": "Número de mensajes:",
|
||||
"view_packet_details": "Más Detalles"
|
||||
},
|
||||
|
||||
"map": {
|
||||
"channel": "Canal:",
|
||||
"model": "Modelo:",
|
||||
"role": "Rol:",
|
||||
"filter_routers_only": "Mostrar solo enrutadores",
|
||||
"share_view": "Compartir esta vista",
|
||||
"reset_filters": "Restablecer filtros",
|
||||
"channel_label": "Canal:",
|
||||
"model_label": "Modelo:",
|
||||
"role_label": "Rol:",
|
||||
"last_seen": "Visto por última vez:",
|
||||
"firmware": "Firmware:",
|
||||
"show_routers_only": "Mostrar solo enrutadores",
|
||||
"share_view": "Compartir esta vista"
|
||||
"link_copied": "¡Enlace copiado!",
|
||||
"legend_traceroute": "Ruta de traceroute (flechas de dirección)",
|
||||
"legend_neighbor": "Vínculo de vecinos"
|
||||
},
|
||||
|
||||
"stats": {
|
||||
"mesh_stats_summary": "Estadísticas de la Malla - Resumen (completas en la base de datos)",
|
||||
"total_nodes": "Nodos Totales",
|
||||
@@ -80,22 +97,22 @@
|
||||
"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:",
|
||||
"top_traffic_nodes": "Tráfico de Nodos (24h)",
|
||||
"channel": "Canal",
|
||||
"search": "Buscar",
|
||||
"search_placeholder": "Buscar nodos...",
|
||||
"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."
|
||||
"packets_sent": "Enviados (24h)",
|
||||
"times_seen": "Visto (24h)",
|
||||
"avg_gateways": "Promedio de Gateways",
|
||||
"showing_nodes": "Mostrando",
|
||||
"nodes_suffix": "nodos"
|
||||
},
|
||||
"nodegraph":
|
||||
{
|
||||
|
||||
"nodegraph": {
|
||||
"channel_label": "Canal:",
|
||||
"search_placeholder": "Buscar nodo...",
|
||||
"search_button": "Buscar",
|
||||
@@ -108,5 +125,73 @@
|
||||
"other": "Otro",
|
||||
"unknown": "Desconocido",
|
||||
"node_not_found": "¡Nodo no encontrado en el canal actual!"
|
||||
},
|
||||
|
||||
"firehose": {
|
||||
"live_feed": "📡 Flujo en vivo",
|
||||
"pause": "Pausar",
|
||||
"resume": "Reanudar",
|
||||
"time": "Hora",
|
||||
"packet_id": "ID de paquete",
|
||||
"from": "De",
|
||||
"to": "A",
|
||||
"port": "Puerto",
|
||||
"direct_to_mqtt": "Directo a MQTT",
|
||||
"all_broadcast": "Todos"
|
||||
},
|
||||
|
||||
"node": {
|
||||
"specifications": "Especificaciones",
|
||||
"node_id": "ID de Nodo",
|
||||
"long_name": "Nombre Largo",
|
||||
"short_name": "Nombre Corto",
|
||||
"hw_model": "Modelo de Hardware",
|
||||
"firmware": "Firmware",
|
||||
"role": "Rol",
|
||||
"channel": "Canal",
|
||||
"latitude": "Latitud",
|
||||
"longitude": "Longitud",
|
||||
"last_update": "Última Actualización",
|
||||
"battery_voltage": "Batería y voltaje",
|
||||
"air_channel": "Utilización del aire y del canal",
|
||||
"environment": "Métricas Ambientales",
|
||||
"neighbors_chart": "Vecinos (Relación Señal/Ruido)",
|
||||
"expand": "Ampliar",
|
||||
"export_csv": "Exportar CSV",
|
||||
"time": "Hora",
|
||||
"packet_id": "ID del Paquete",
|
||||
"from": "De",
|
||||
"to": "A",
|
||||
"port": "Puerto",
|
||||
"direct_to_mqtt": "Directo a MQTT",
|
||||
"all_broadcast": "Todos",
|
||||
"statistics": "Estadísticas",
|
||||
"last_24h": "24h",
|
||||
"packets_sent": "Paquetes enviados",
|
||||
"times_seen": "Veces visto"
|
||||
},
|
||||
|
||||
"packet": {
|
||||
"loading": "Cargando información del paquete...",
|
||||
"packet_id_label": "ID del Paquete",
|
||||
"from_node": "De",
|
||||
"to_node": "A",
|
||||
"channel": "Canal",
|
||||
"port": "Puerto",
|
||||
"raw_payload": "Payload sin procesar",
|
||||
"decoded_telemetry": "Telemetría Decodificada",
|
||||
"location": "Ubicación",
|
||||
"seen_by": "Visto por",
|
||||
"gateway": "Gateway",
|
||||
"rssi": "RSSI",
|
||||
"snr": "SNR",
|
||||
"hops": "Saltos",
|
||||
"time": "Hora",
|
||||
"packet_source": "Origen del Paquete",
|
||||
"distance": "Distancia",
|
||||
"node_id_short": "ID de Nodo",
|
||||
"all_broadcast": "Todos",
|
||||
"direct_to_mqtt": "Directo a MQTT",
|
||||
"signal": "Señal"
|
||||
}
|
||||
}
|
||||
|
||||
243
meshview/migrations.py
Normal file
243
meshview/migrations.py
Normal file
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Database migration management for MeshView.
|
||||
|
||||
This module provides utilities for:
|
||||
- Running Alembic migrations programmatically
|
||||
- Checking database schema versions
|
||||
- Coordinating migrations between writer and reader apps
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from alembic.config import Config
|
||||
from alembic.runtime.migration import MigrationContext
|
||||
from alembic.script import ScriptDirectory
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine
|
||||
|
||||
from alembic import command
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_alembic_config(database_url: str) -> Config:
|
||||
"""
|
||||
Get Alembic configuration with the database URL set.
|
||||
|
||||
Args:
|
||||
database_url: SQLAlchemy database connection string
|
||||
|
||||
Returns:
|
||||
Configured Alembic Config object
|
||||
"""
|
||||
# Get the alembic.ini path (in project root)
|
||||
alembic_ini = Path(__file__).parent.parent / "alembic.ini"
|
||||
|
||||
config = Config(str(alembic_ini))
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
|
||||
return config
|
||||
|
||||
|
||||
async def get_current_revision(engine: AsyncEngine) -> str | None:
|
||||
"""
|
||||
Get the current database schema revision.
|
||||
|
||||
Args:
|
||||
engine: Async SQLAlchemy engine
|
||||
|
||||
Returns:
|
||||
Current revision string, or None if no migrations applied
|
||||
"""
|
||||
async with engine.connect() as connection:
|
||||
|
||||
def _get_revision(conn):
|
||||
context = MigrationContext.configure(conn)
|
||||
return context.get_current_revision()
|
||||
|
||||
revision = await connection.run_sync(_get_revision)
|
||||
return revision
|
||||
|
||||
|
||||
async def get_head_revision(database_url: str) -> str | None:
|
||||
"""
|
||||
Get the head (latest) revision from migration scripts.
|
||||
|
||||
Args:
|
||||
database_url: Database connection string
|
||||
|
||||
Returns:
|
||||
Head revision string, or None if no migrations exist
|
||||
"""
|
||||
config = get_alembic_config(database_url)
|
||||
script_dir = ScriptDirectory.from_config(config)
|
||||
|
||||
head = script_dir.get_current_head()
|
||||
return head
|
||||
|
||||
|
||||
async def is_database_up_to_date(engine: AsyncEngine, database_url: str) -> bool:
|
||||
"""
|
||||
Check if database is at the latest schema version.
|
||||
|
||||
Args:
|
||||
engine: Async SQLAlchemy engine
|
||||
database_url: Database connection string
|
||||
|
||||
Returns:
|
||||
True if database is up to date, False otherwise
|
||||
"""
|
||||
current = await get_current_revision(engine)
|
||||
head = await get_head_revision(database_url)
|
||||
|
||||
# If there are no migrations yet, consider it up to date
|
||||
if head is None:
|
||||
return True
|
||||
|
||||
return current == head
|
||||
|
||||
|
||||
def run_migrations(database_url: str) -> None:
|
||||
"""
|
||||
Run all pending migrations to bring database up to date.
|
||||
|
||||
This is a synchronous operation that runs Alembic migrations.
|
||||
Should be called by the writer app on startup.
|
||||
|
||||
Args:
|
||||
database_url: Database connection string
|
||||
"""
|
||||
logger.info("Running database migrations...")
|
||||
import sys
|
||||
|
||||
sys.stdout.flush()
|
||||
|
||||
config = get_alembic_config(database_url)
|
||||
|
||||
try:
|
||||
# Run migrations to head
|
||||
logger.info("Calling alembic upgrade command...")
|
||||
sys.stdout.flush()
|
||||
command.upgrade(config, "head")
|
||||
logger.info("Database migrations completed successfully")
|
||||
sys.stdout.flush()
|
||||
except Exception as e:
|
||||
logger.error(f"Error running migrations: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def wait_for_migrations(
|
||||
engine: AsyncEngine, database_url: str, max_retries: int = 30, retry_delay: int = 2
|
||||
) -> bool:
|
||||
"""
|
||||
Wait for database migrations to complete.
|
||||
|
||||
This should be called by the reader app to wait until
|
||||
the database schema is up to date before proceeding.
|
||||
|
||||
Args:
|
||||
engine: Async SQLAlchemy engine
|
||||
database_url: Database connection string
|
||||
max_retries: Maximum number of retry attempts
|
||||
retry_delay: Seconds to wait between retries
|
||||
|
||||
Returns:
|
||||
True if database is up to date, False if max retries exceeded
|
||||
"""
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
if await is_database_up_to_date(engine, database_url):
|
||||
logger.info("Database schema is up to date")
|
||||
return True
|
||||
|
||||
current = await get_current_revision(engine)
|
||||
head = await get_head_revision(database_url)
|
||||
|
||||
logger.info(
|
||||
f"Database schema not up to date (current: {current}, head: {head}). "
|
||||
f"Waiting... (attempt {attempt + 1}/{max_retries})"
|
||||
)
|
||||
|
||||
await asyncio.sleep(retry_delay)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Error checking database version (attempt {attempt + 1}/{max_retries}): {e}"
|
||||
)
|
||||
await asyncio.sleep(retry_delay)
|
||||
|
||||
logger.error(f"Database schema not up to date after {max_retries} attempts")
|
||||
return False
|
||||
|
||||
|
||||
async def create_migration_status_table(engine: AsyncEngine) -> None:
|
||||
"""
|
||||
Create a simple status table for migration coordination.
|
||||
|
||||
This table can be used to signal when migrations are in progress.
|
||||
|
||||
Args:
|
||||
engine: Async SQLAlchemy engine
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("""
|
||||
CREATE TABLE IF NOT EXISTS migration_status (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
in_progress BOOLEAN NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
)
|
||||
|
||||
# Insert initial row if not exists
|
||||
await conn.execute(
|
||||
text("""
|
||||
INSERT OR IGNORE INTO migration_status (id, in_progress)
|
||||
VALUES (1, 0)
|
||||
""")
|
||||
)
|
||||
|
||||
|
||||
async def set_migration_in_progress(engine: AsyncEngine, in_progress: bool) -> None:
|
||||
"""
|
||||
Set the migration in-progress flag.
|
||||
|
||||
Args:
|
||||
engine: Async SQLAlchemy engine
|
||||
in_progress: True if migration is in progress, False otherwise
|
||||
"""
|
||||
async with engine.begin() as conn:
|
||||
await conn.execute(
|
||||
text("""
|
||||
UPDATE migration_status
|
||||
SET in_progress = :in_progress,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = 1
|
||||
"""),
|
||||
{"in_progress": in_progress},
|
||||
)
|
||||
|
||||
|
||||
async def is_migration_in_progress(engine: AsyncEngine) -> bool:
|
||||
"""
|
||||
Check if a migration is currently in progress.
|
||||
|
||||
Args:
|
||||
engine: Async SQLAlchemy engine
|
||||
|
||||
Returns:
|
||||
True if migration is in progress, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(
|
||||
text("SELECT in_progress FROM migration_status WHERE id = 1")
|
||||
)
|
||||
row = result.fetchone()
|
||||
return bool(row[0]) if row else False
|
||||
except Exception:
|
||||
# If table doesn't exist or query fails, assume no migration in progress
|
||||
return False
|
||||
@@ -23,8 +23,14 @@ class Node(Base):
|
||||
last_long: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
channel: Mapped[str] = mapped_column(nullable=True)
|
||||
last_update: Mapped[datetime] = mapped_column(nullable=True)
|
||||
first_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
last_seen_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
__table_args__ = (Index("idx_node_node_id", "node_id"),)
|
||||
__table_args__ = (
|
||||
Index("idx_node_node_id", "node_id"),
|
||||
Index("idx_node_first_seen_us", "first_seen_us"),
|
||||
Index("idx_node_last_seen_us", "last_seen_us"),
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
@@ -50,14 +56,17 @@ class Packet(Base):
|
||||
)
|
||||
payload: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
channel: Mapped[str] = mapped_column(nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_packet_from_node_id", "from_node_id"),
|
||||
Index("idx_packet_to_node_id", "to_node_id"),
|
||||
Index("idx_packet_import_time", desc("import_time")),
|
||||
Index("idx_packet_import_time_us", desc("import_time_us")),
|
||||
# Composite index for /top endpoint performance - filters by from_node_id AND import_time
|
||||
Index("idx_packet_from_node_time", "from_node_id", desc("import_time")),
|
||||
Index("idx_packet_from_node_time_us", "from_node_id", desc("import_time_us")),
|
||||
)
|
||||
|
||||
|
||||
@@ -78,11 +87,13 @@ class PacketSeen(Base):
|
||||
rx_rssi: Mapped[int] = mapped_column(nullable=True)
|
||||
topic: Mapped[str] = mapped_column(nullable=True)
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
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"),
|
||||
Index("idx_packet_seen_import_time_us", "import_time_us"),
|
||||
)
|
||||
|
||||
|
||||
@@ -98,5 +109,10 @@ class Traceroute(Base):
|
||||
done: Mapped[bool] = mapped_column(nullable=True)
|
||||
route: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time: Mapped[datetime] = mapped_column(nullable=True)
|
||||
route_return: Mapped[bytes] = mapped_column(nullable=True)
|
||||
import_time_us: Mapped[int] = mapped_column(BigInteger, nullable=True)
|
||||
|
||||
__table_args__ = (Index("idx_traceroute_import_time", "import_time"),)
|
||||
__table_args__ = (
|
||||
Index("idx_traceroute_import_time", "import_time"),
|
||||
Index("idx_traceroute_import_time_us", "import_time_us"),
|
||||
)
|
||||
|
||||
@@ -37,6 +37,9 @@ async def process_envelope(topic, env):
|
||||
await session.execute(select(Node).where(Node.node_id == node_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
|
||||
if node:
|
||||
node.node_id = node_id
|
||||
node.long_name = map_report.long_name
|
||||
@@ -47,7 +50,10 @@ async def process_envelope(topic, env):
|
||||
node.last_lat = map_report.latitude_i
|
||||
node.last_long = map_report.longitude_i
|
||||
node.firmware = map_report.firmware_version
|
||||
node.last_update = datetime.datetime.now()
|
||||
node.last_update = now
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
node.first_seen_us = now_us
|
||||
else:
|
||||
node = Node(
|
||||
id=user_id,
|
||||
@@ -60,7 +66,9 @@ async def process_envelope(topic, env):
|
||||
firmware=map_report.firmware_version,
|
||||
last_lat=map_report.latitude_i,
|
||||
last_long=map_report.longitude_i,
|
||||
last_update=datetime.datetime.now(),
|
||||
last_update=now,
|
||||
first_seen_us=now_us,
|
||||
last_seen_us=now_us,
|
||||
)
|
||||
session.add(node)
|
||||
except Exception as e:
|
||||
@@ -74,12 +82,11 @@ async def process_envelope(topic, env):
|
||||
async with mqtt_database.async_session() as session:
|
||||
# --- Packet insert with ON CONFLICT DO NOTHING
|
||||
result = await session.execute(select(Packet).where(Packet.id == env.packet.id))
|
||||
# FIXME: Not Used
|
||||
# new_packet = False
|
||||
packet = result.scalar_one_or_none()
|
||||
if not packet:
|
||||
# FIXME: Not Used
|
||||
# new_packet = True
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
stmt = (
|
||||
sqlite_insert(Packet)
|
||||
.values(
|
||||
@@ -88,7 +95,8 @@ async def process_envelope(topic, env):
|
||||
from_node_id=getattr(env.packet, "from"),
|
||||
to_node_id=env.packet.to,
|
||||
payload=env.packet.SerializeToString(),
|
||||
import_time=datetime.datetime.now(),
|
||||
import_time=now,
|
||||
import_time_us=now_us,
|
||||
channel=env.channel_id,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=["id"])
|
||||
@@ -112,6 +120,8 @@ async def process_envelope(topic, env):
|
||||
)
|
||||
)
|
||||
if not result.scalar_one_or_none():
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
seen = PacketSeen(
|
||||
packet_id=env.packet.id,
|
||||
node_id=int(env.gateway_id[1:], 16),
|
||||
@@ -122,7 +132,8 @@ async def process_envelope(topic, env):
|
||||
hop_limit=env.packet.hop_limit,
|
||||
hop_start=env.packet.hop_start,
|
||||
topic=topic,
|
||||
import_time=datetime.datetime.now(),
|
||||
import_time=now,
|
||||
import_time_us=now_us,
|
||||
)
|
||||
session.add(seen)
|
||||
|
||||
@@ -153,6 +164,9 @@ async def process_envelope(topic, env):
|
||||
await session.execute(select(Node).where(Node.id == user.id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
|
||||
if node:
|
||||
node.node_id = node_id
|
||||
node.long_name = user.long_name
|
||||
@@ -160,7 +174,10 @@ async def process_envelope(topic, env):
|
||||
node.hw_model = hw_model
|
||||
node.role = role
|
||||
node.channel = env.channel_id
|
||||
node.last_update = datetime.datetime.now()
|
||||
node.last_update = now
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
node.first_seen_us = now_us
|
||||
else:
|
||||
node = Node(
|
||||
id=user.id,
|
||||
@@ -170,7 +187,9 @@ async def process_envelope(topic, env):
|
||||
hw_model=hw_model,
|
||||
role=role,
|
||||
channel=env.channel_id,
|
||||
last_update=datetime.datetime.now(),
|
||||
last_update=now,
|
||||
first_seen_us=now_us,
|
||||
last_seen_us=now_us,
|
||||
)
|
||||
session.add(node)
|
||||
except Exception as e:
|
||||
@@ -187,34 +206,32 @@ async def process_envelope(topic, env):
|
||||
await session.execute(select(Node).where(Node.node_id == from_node_id))
|
||||
).scalar_one_or_none()
|
||||
if node:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
node.last_lat = position.latitude_i
|
||||
node.last_long = position.longitude_i
|
||||
node.last_update = now
|
||||
node.last_seen_us = now_us
|
||||
if node.first_seen_us is None:
|
||||
node.first_seen_us = now_us
|
||||
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:
|
||||
packet_id = env.packet.id
|
||||
else:
|
||||
result = await session.execute(
|
||||
select(Packet).where(Packet.id == env.packet.decoded.request_id)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
packet_id = env.packet.decoded.request_id
|
||||
packet_id = env.packet.id
|
||||
if packet_id is not None:
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
now_us = int(now.timestamp() * 1_000_000)
|
||||
session.add(
|
||||
Traceroute(
|
||||
packet_id=packet_id,
|
||||
route=env.packet.decoded.payload,
|
||||
done=not env.packet.decoded.want_response,
|
||||
gateway_node_id=int(env.gateway_id[1:], 16),
|
||||
import_time=datetime.datetime.now(),
|
||||
import_time=now,
|
||||
import_time_us=now_us,
|
||||
)
|
||||
)
|
||||
|
||||
await session.commit()
|
||||
|
||||
# if new_packet:
|
||||
# await packet.awaitable_attrs.to_node
|
||||
# await packet.awaitable_attrs.from_node
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>API Documentation - Config</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
|
||||
<style>
|
||||
body { margin: 0; background: #ffffff; color: #000; }
|
||||
#swagger-ui { background: #ffffff; color: #000; }
|
||||
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
|
||||
.swagger-ui .topbar,
|
||||
.swagger-ui .info,
|
||||
.swagger-ui .opblock-summary-description,
|
||||
.swagger-ui .parameters-col_description,
|
||||
.swagger-ui .response-col_description { color: #000 !important; }
|
||||
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
|
||||
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
|
||||
.swagger-ui .opblock-section-header { color: #000 !important; }
|
||||
.swagger-ui .parameters,
|
||||
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
|
||||
.swagger-ui table { color: #000 !important; }
|
||||
.swagger-ui a { color: #1a0dab !important; }
|
||||
.swagger-ui input,
|
||||
.swagger-ui select,
|
||||
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const spec = {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Site Config API",
|
||||
version: "1.0.0",
|
||||
description: "API for retrieving the site configuration. This endpoint does not take any parameters."
|
||||
},
|
||||
paths: {
|
||||
"/api/config": {
|
||||
get: {
|
||||
summary: "Get site configuration",
|
||||
description: "Returns the current site configuration object.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
site_config: {
|
||||
type: "object",
|
||||
additionalProperties: true,
|
||||
example: {
|
||||
site_name: "MeshView",
|
||||
firehose_interval: 1000,
|
||||
starting: "/nodes",
|
||||
theme: "dark"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
description: "Server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string", example: "Internal server error" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
spec,
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,109 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>API Documentation - Edges</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
#swagger-ui { background: #ffffff; color: #000; }
|
||||
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
|
||||
.swagger-ui .topbar,
|
||||
.swagger-ui .info,
|
||||
.swagger-ui .opblock-summary-description,
|
||||
.swagger-ui .parameters-col_description,
|
||||
.swagger-ui .response-col_description { color: #000 !important; }
|
||||
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
|
||||
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
|
||||
.swagger-ui .opblock-section-header { color: #000 !important; }
|
||||
.swagger-ui .parameters,
|
||||
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
|
||||
.swagger-ui table { color: #000 !important; }
|
||||
.swagger-ui a { color: #1a0dab !important; }
|
||||
.swagger-ui input,
|
||||
.swagger-ui select,
|
||||
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const spec = {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Network Edges API",
|
||||
version: "1.0.0",
|
||||
description: "API for retrieving network edges derived from traceroutes and neighbor info packets, with optional type filtering."
|
||||
},
|
||||
paths: {
|
||||
"/api/edges": {
|
||||
get: {
|
||||
summary: "Get network edges",
|
||||
description: "Returns edges between nodes in the network. Optionally filter by type (`traceroute` or `neighbor`).",
|
||||
parameters: [
|
||||
{
|
||||
name: "type",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Optional filter to only return edges of this type (`traceroute` or `neighbor`).",
|
||||
schema: { type: "string", enum: ["traceroute", "neighbor"] }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
edges: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
from: { type: "integer", example: 101 },
|
||||
to: { type: "integer", example: 102 },
|
||||
type: { type: "string", example: "traceroute" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
description: "Invalid request parameters",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { error: { type: "string" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
spec,
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,134 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>API Documentation - Nodes</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
|
||||
<style>
|
||||
body { margin: 0; background: #ffffff; color: #000; }
|
||||
#swagger-ui { background: #ffffff; color: #000; }
|
||||
.swagger-ui { background-color: #ffffff !important; color: #000 !important; }
|
||||
.swagger-ui .topbar,
|
||||
.swagger-ui .info,
|
||||
.swagger-ui .opblock-summary-description,
|
||||
.swagger-ui .parameters-col_description,
|
||||
.swagger-ui .response-col_description { color: #000 !important; }
|
||||
.swagger-ui .opblock { background-color: #f9f9f9 !important; border-color: #ddd !important; }
|
||||
.swagger-ui .opblock-summary { background-color: #eaeaea !important; color: #000 !important; }
|
||||
.swagger-ui .opblock-section-header { color: #000 !important; }
|
||||
.swagger-ui .parameters,
|
||||
.swagger-ui .response { background-color: #fafafa !important; color: #000 !important; }
|
||||
.swagger-ui table { color: #000 !important; }
|
||||
.swagger-ui a { color: #1a0dab !important; }
|
||||
.swagger-ui input,
|
||||
.swagger-ui select,
|
||||
.swagger-ui textarea { background-color: #fff !important; color: #000 !important; border: 1px solid #ccc !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const spec = {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Network Nodes API",
|
||||
version: "1.0.0",
|
||||
description: "API for retrieving nodes in the network with optional filters by last seen time."
|
||||
},
|
||||
paths: {
|
||||
"/api/nodes": {
|
||||
get: {
|
||||
summary: "Get network nodes",
|
||||
description: "Returns a list of nodes with optional filtering by recent activity.",
|
||||
parameters: [
|
||||
{
|
||||
name: "hours",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Return nodes seen in the last X hours.",
|
||||
schema: { type: "integer", example: 24 }
|
||||
},
|
||||
{
|
||||
name: "days",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Return nodes seen in the last X days.",
|
||||
schema: { type: "integer", example: 7 }
|
||||
},
|
||||
{
|
||||
name: "last_seen_after",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Return nodes last seen after this ISO8601 timestamp.",
|
||||
schema: { type: "string", format: "date-time", example: "2025-08-25T14:00:00" }
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
nodes: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
node_id: { type: "integer", example: 101 },
|
||||
long_name: { type: "string", example: "Node Alpha" },
|
||||
short_name: { type: "string", example: "A" },
|
||||
channel: { type: "string", example: "2" },
|
||||
last_seen: { type: "string", format: "date-time", example: "2025-08-25T12:00:00" },
|
||||
last_lat: { type: "number", format: "float", example: 37.7749 },
|
||||
last_long: { type: "number", format: "float", example: -122.4194 },
|
||||
hardware: { type: "string", example: "Heltec V3" },
|
||||
firmware: { type: "string", example: "1.0.5" },
|
||||
role: { type: "string", example: "router" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
description: "Invalid request parameters",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { error: { type: "string" } } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
description: "Server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { type: "object", properties: { error: { type: "string" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
spec,
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,167 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>API Documentation - Packets</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
#swagger-ui {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Override Swagger UI colors for white background */
|
||||
.swagger-ui {
|
||||
background-color: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar,
|
||||
.swagger-ui .info,
|
||||
.swagger-ui .opblock-summary-description,
|
||||
.swagger-ui .parameters-col_description,
|
||||
.swagger-ui .response-col_description {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock {
|
||||
background-color: #f9f9f9 !important;
|
||||
border-color: #ddd !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-summary {
|
||||
background-color: #eaeaea !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-section-header {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .parameters,
|
||||
.swagger-ui .response {
|
||||
background-color: #fafafa !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui table {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui a {
|
||||
color: #1a0dab !important; /* classic link blue */
|
||||
}
|
||||
|
||||
.swagger-ui input,
|
||||
.swagger-ui select,
|
||||
.swagger-ui textarea {
|
||||
background-color: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const spec = {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Packets API",
|
||||
version: "1.0.0",
|
||||
description: "API for retrieving packet records with optional filters."
|
||||
},
|
||||
paths: {
|
||||
"/api/packets": {
|
||||
get: {
|
||||
summary: "Get packets",
|
||||
description: "Returns a list of recent packets, optionally filtered by a timestamp and limited by count.",
|
||||
parameters: [
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Maximum number of packets to return. Default is 200.",
|
||||
schema: {
|
||||
type: "integer",
|
||||
default: 200
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "since",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Only return packets imported after this ISO8601 timestamp (e.g., `2025-08-12T14:15:20`).",
|
||||
schema: {
|
||||
type: "string",
|
||||
format: "date-time"
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
packets: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "integer", example: 196988973 },
|
||||
from_node_id: { type: "integer", example: 2381019191 },
|
||||
to_node_id: { type: "integer", example: 1234567890 },
|
||||
portnum: { type: "integer", example: 1 },
|
||||
import_time: { type: "string", format: "date-time", example: "2025-08-12T14:15:20.503827" },
|
||||
payload: { type: "string", example: "Hello Mesh" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
description: "Internal server error",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string", example: "Failed to fetch packets" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
spec,
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,210 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>API Documentation - Packet Stats</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist/swagger-ui.css" />
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
#swagger-ui {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
|
||||
/* Override Swagger UI colors for white background */
|
||||
.swagger-ui {
|
||||
background-color: #ffffff !important;
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar,
|
||||
.swagger-ui .info,
|
||||
.swagger-ui .opblock-summary-description,
|
||||
.swagger-ui .parameters-col_description,
|
||||
.swagger-ui .response-col_description {
|
||||
color: #000000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock {
|
||||
background-color: #f9f9f9 !important;
|
||||
border-color: #ddd !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-summary {
|
||||
background-color: #eaeaea !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-section-header {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui .parameters,
|
||||
.swagger-ui .response {
|
||||
background-color: #fafafa !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui table {
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
.swagger-ui a {
|
||||
color: #1a0dab !important; /* classic link blue */
|
||||
}
|
||||
|
||||
.swagger-ui input,
|
||||
.swagger-ui select,
|
||||
.swagger-ui textarea {
|
||||
background-color: #fff !important;
|
||||
color: #000 !important;
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
|
||||
<script src="https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js"></script>
|
||||
<script>
|
||||
const spec = {
|
||||
openapi: "3.0.0",
|
||||
info: {
|
||||
title: "Packet Statistics API",
|
||||
version: "1.0.0",
|
||||
description: "API for retrieving packet statistics over a given period with optional filters."
|
||||
},
|
||||
paths: {
|
||||
"/api/stats": {
|
||||
get: {
|
||||
summary: "Get packet statistics",
|
||||
description: "Returns packet statistics for a given period type and length, with optional filters.",
|
||||
parameters: [
|
||||
{
|
||||
name: "period_type",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Type of period to group by (`hour` or `day`). Default is `hour`.",
|
||||
schema: {
|
||||
type: "string",
|
||||
enum: ["hour", "day"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "length",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Number of periods to include. Default is 24.",
|
||||
schema: {
|
||||
type: "integer",
|
||||
default: 24
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "channel",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Filter by channel name.",
|
||||
schema: {
|
||||
type: "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "portnum",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Filter by port number.",
|
||||
schema: {
|
||||
type: "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "to_node",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Filter by destination node ID.",
|
||||
schema: {
|
||||
type: "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "from_node",
|
||||
in: "query",
|
||||
required: false,
|
||||
description: "Filter by source node ID.",
|
||||
schema: {
|
||||
type: "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Successful response",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
hourly: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period_type: { type: "string" },
|
||||
length: { type: "integer" },
|
||||
filters: { type: "object" },
|
||||
data: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
period: { type: "string", example: "2025-08-06 19:00" },
|
||||
node_id: { type: "integer" },
|
||||
long_name: { type: "string" },
|
||||
short_name: { type: "string" },
|
||||
packets: { type: "integer" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
description: "Invalid request parameters",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.onload = () => {
|
||||
SwaggerUIBundle({
|
||||
spec,
|
||||
dom_id: '#swagger-ui',
|
||||
presets: [
|
||||
SwaggerUIBundle.presets.apis,
|
||||
SwaggerUIBundle.SwaggerUIStandalonePreset
|
||||
],
|
||||
layout: "BaseLayout"
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +1,273 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>API Index</title>
|
||||
<style>
|
||||
<meta charset="utf-8">
|
||||
<title>Meshview API Documentation</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #1e1e1e;
|
||||
color: #eaeaea;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #121212;
|
||||
color: #eee;
|
||||
font-family: monospace;
|
||||
margin: 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
background: #2a2a2a;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
font-size: 1.6em;
|
||||
font-weight: bold;
|
||||
h1, h2, h3 { color: #79c0ff; }
|
||||
code {
|
||||
background: #1e1e1e;
|
||||
padding: 3px 6px;
|
||||
border-radius: 4px;
|
||||
color: #ffd479;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 30px auto;
|
||||
padding: 20px;
|
||||
.endpoint {
|
||||
border: 1px solid #333;
|
||||
padding: 12px;
|
||||
margin-bottom: 18px;
|
||||
border-radius: 8px;
|
||||
background: #1a1a1a;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
margin-right: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
li {
|
||||
background: #272b2f;
|
||||
border: 1px solid #474b4e;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 15px;
|
||||
border-radius: 10px;
|
||||
transition: background 0.2s;
|
||||
.get { background: #0066cc; }
|
||||
.path { font-weight: bold; color: #fff; }
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 8px;
|
||||
}
|
||||
li:hover {
|
||||
background: #33383d;
|
||||
th, td {
|
||||
border: 1px solid #444;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
a {
|
||||
color: #4cafef;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
th {
|
||||
background: #222;
|
||||
color: #9ddcff;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0 0 0;
|
||||
font-size: 0.9em;
|
||||
color: #bbbbbb;
|
||||
.example {
|
||||
margin-top: 10px;
|
||||
padding: 10px;
|
||||
background: #161616;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
<header>
|
||||
API Index
|
||||
</header>
|
||||
<h1>Meshview API Documentation</h1>
|
||||
<p>This page describes all REST endpoints provided by Meshview.</p>
|
||||
|
||||
<div class="container">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="/api-chat">Chat API</a>
|
||||
<p> View chat messages.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api-nodes">Node API</a>
|
||||
<p>Retrieve node information.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api-packets">Packet API</a>
|
||||
<p>Access raw packet data.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api-stats">Statistics API </a>
|
||||
<p>View system and traffic statistics.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api-edges">Edges API</a>
|
||||
<p>Get edges details.</p>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/api-config">Configuration API</a>
|
||||
<p>Get and update configuration details.</p>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!------------------------------ NODES ------------------------------>
|
||||
<h2>/api/nodes</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/nodes</span>
|
||||
<p>Returns a list of mesh nodes.</p>
|
||||
|
||||
<h3>Query Parameters</h3>
|
||||
<table>
|
||||
<tr><th>Parameter</th><th>Description</th></tr>
|
||||
<tr><td>role</td><td>Filter by node role</td></tr>
|
||||
<tr><td>channel</td><td>Filter by channel</td></tr>
|
||||
<tr><td>hw_model</td><td>Hardware model filter</td></tr>
|
||||
<tr><td>days_active</td><td>Only nodes seen within X days</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="example">
|
||||
<b>Example:</b><br>
|
||||
<code>/api/nodes?days_active=3</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ PACKETS ------------------------------>
|
||||
<h2>/api/packets</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/packets</span>
|
||||
<p>Fetch packets with many filters. Returns decoded packet data.</p>
|
||||
|
||||
<h3>Query Parameters</h3>
|
||||
<table>
|
||||
<tr><th>Parameter</th><th>Description</th></tr>
|
||||
<tr><td>packet_id</td><td>Return exactly one packet</td></tr>
|
||||
<tr><td>limit</td><td>Max number of results (1–100)</td></tr>
|
||||
<tr><td>since</td><td>Only packets newer than import_time_us</td></tr>
|
||||
<tr><td>from_node_id</td><td>Filter by sender node</td></tr>
|
||||
<tr><td>to_node_id</td><td>Filter by destination node</td></tr>
|
||||
<tr><td>node_id</td><td>Legacy: match either from or to</td></tr>
|
||||
<tr><td>portnum</td><td>Filter by port number</td></tr>
|
||||
<tr><td>contains</td><td>Substring filter for payload</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="example">
|
||||
<b>Example:</b><br>
|
||||
<code>/api/packets?from_node_id=123&limit=100</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ PACKETS SEEN ------------------------------>
|
||||
<h2>/api/packets_seen/{packet_id}</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/packets_seen/<packet_id></span>
|
||||
<p>Returns list of gateways that heard the packet (RSSI/SNR/hops).</p>
|
||||
|
||||
<div class="example">
|
||||
<b>Example:</b><br>
|
||||
<code>/api/packets_seen/3314808102</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ STATS ------------------------------>
|
||||
<h2>/api/stats</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/stats</span>
|
||||
<p>Returns aggregated packet statistics for a node or globally.</p>
|
||||
|
||||
<h3>Query Parameters</h3>
|
||||
<table>
|
||||
<tr><th>Parameter</th><th>Description</th></tr>
|
||||
<tr><td>period_type</td><td>"hour" or "day"</td></tr>
|
||||
<tr><td>length</td><td>How many hours/days</td></tr>
|
||||
<tr><td>node</td><td>Node ID for combined sent+seen stats</td></tr>
|
||||
<tr><td>from_node</td><td>Filter by sender</td></tr>
|
||||
<tr><td>to_node</td><td>Filter by receiver</td></tr>
|
||||
<tr><td>portnum</td><td>Filter by port</td></tr>
|
||||
<tr><td>channel</td><td>Filter by channel</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="example">
|
||||
<b>Example:</b><br>
|
||||
<code>/api/stats?node=1128180332&period_type=day&length=1</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ STATS COUNT ------------------------------>
|
||||
<h2>/api/stats/count</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/stats/count</span>
|
||||
<p>
|
||||
Returns <b>total packets</b> and <b>total packet_seen entries</b>.
|
||||
When no filters are provided, it returns global totals.
|
||||
When filters are specified, they narrow the time, channel,
|
||||
direction, or specific packet.
|
||||
</p>
|
||||
|
||||
<h3>Query Parameters</h3>
|
||||
<table>
|
||||
<tr><th>Parameter</th><th>Description</th></tr>
|
||||
<tr><td>period_type</td><td>"hour" or "day"</td></tr>
|
||||
<tr><td>length</td><td>Number of hours or days (depends on period_type)</td></tr>
|
||||
<tr><td>channel</td><td>Filter by channel</td></tr>
|
||||
<tr><td>from_node</td><td>Only packets sent by this node</td></tr>
|
||||
<tr><td>to_node</td><td>Only packets received by this node</td></tr>
|
||||
<tr><td>packet_id</td><td>Filter seen counts for specific packet_id</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="example">
|
||||
<b>Example:</b><br>
|
||||
<code>/api/stats/count?from_node=1128180332&period_type=day&length=1</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!------------------------------ EDGES ------------------------------>
|
||||
<h2>/api/edges</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/edges</span>
|
||||
<p>Returns traceroute and/or neighbor edges for graph rendering.</p>
|
||||
|
||||
<h3>Query Parameters</h3>
|
||||
<table>
|
||||
<tr><th>Parameter</th><th>Description</th></tr>
|
||||
<tr><td>type</td><td>"traceroute", "neighbor", or omitted for both</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="example">
|
||||
<b>Example:</b><br>
|
||||
<code>/api/edges?type=neighbor</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ CONFIG ------------------------------>
|
||||
<h2>/api/config</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/config</span>
|
||||
<p>Returns Meshview configuration (site, MQTT, cleanup, etc.).</p>
|
||||
|
||||
<div class="example">
|
||||
<code>/api/config</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ LANG ------------------------------>
|
||||
<h2>/api/lang</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/api/lang</span>
|
||||
<p>Returns translated text for the UI.</p>
|
||||
|
||||
<h3>Parameters</h3>
|
||||
<table>
|
||||
<tr><th>lang</th><td>Language code (e.g. "en")</td></tr>
|
||||
<tr><th>section</th><td>Optional UI section (firehose, map, top...)</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="example">
|
||||
<code>/api/lang?lang=en§ion=firehose</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ HEALTH ------------------------------>
|
||||
<h2>/health</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/health</span>
|
||||
<p>Returns API + database status.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<!------------------------------ VERSION ------------------------------>
|
||||
<h2>/version</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="path">/version</span>
|
||||
<p>Returns Meshview version and Git revision.</p>
|
||||
</div>
|
||||
|
||||
|
||||
<br><br>
|
||||
<hr>
|
||||
<p style="text-align:center; color:#666;">Meshview API — generated documentation</p>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -190,32 +190,51 @@ body { margin: 0; font-family: monospace; background: #121212; color: #eee; }
|
||||
activeBlinks.set(marker,interval);
|
||||
}
|
||||
|
||||
let lastImportTime=null;
|
||||
let lastImportTimeUs = 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); }
|
||||
const res = await fetch(`/api/packets?limit=1`);
|
||||
const data = await res.json();
|
||||
lastImportTimeUs = data.packets?.[0]?.import_time_us || 0;
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewPackets(){
|
||||
if(!lastImportTime) return;
|
||||
if (!lastImportTimeUs) return;
|
||||
|
||||
try{
|
||||
const res=await fetch(`/api/packets?since=${lastImportTime}`);
|
||||
const data=await res.json();
|
||||
const res = await fetch(`/api/packets?since=${lastImportTimeUs}`);
|
||||
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);
|
||||
|
||||
let latest = lastImportTimeUs;
|
||||
|
||||
data.packets.forEach(packet => {
|
||||
|
||||
// Track newest microsecond timestamp
|
||||
if (packet.import_time_us && packet.import_time_us > latest) {
|
||||
latest = packet.import_time_us;
|
||||
}
|
||||
|
||||
// Look up marker and blink it
|
||||
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); }
|
||||
|
||||
lastImportTimeUs = latest;
|
||||
|
||||
}catch(err){
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if(mapInterval>0){ fetchLatestPacket(); setInterval(fetchNewPackets,mapInterval*1000); }
|
||||
|
||||
})();
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
<!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" />
|
||||
<style>
|
||||
body { margin: 0; }
|
||||
#map { height: 100vh; width: 100%; }
|
||||
|
||||
#legend {
|
||||
position: absolute; bottom: 10px; right: 10px;
|
||||
background: rgba(0,0,0,0.7);
|
||||
color: white; padding: 8px 12px;
|
||||
font-family: monospace; font-size: 13px;
|
||||
border-radius: 5px; z-index: 1000;
|
||||
}
|
||||
.legend-item { display: flex; align-items: center; margin-bottom: 4px; }
|
||||
.legend-color { width: 16px; height: 16px; margin-right: 6px; border-radius: 4px; }
|
||||
|
||||
.pulse-label span {
|
||||
background: rgba(0,0,0,0.6);
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
pointer-events: none;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="map"></div>
|
||||
<div id="legend"></div>
|
||||
|
||||
<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 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"; }
|
||||
|
||||
// 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
|
||||
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 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
|
||||
}).openTooltip();
|
||||
|
||||
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 });
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// --- 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 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);
|
||||
if (marker instanceof L.CircleMarker) { // only real markers
|
||||
marker.currentPortLabel = portLabels[pkt.portnum] || `${pkt.portnum}`;
|
||||
pulseMarker(marker, getPulseColor(pkt.portnum));
|
||||
}
|
||||
});
|
||||
} catch(err){
|
||||
console.error("Failed to fetch packets:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialize ---
|
||||
loadNodes().then(() => setInterval(pollPackets, 1000));
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +1,8 @@
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from sqlalchemy import func, select, text
|
||||
from sqlalchemy import select, and_, or_, func, cast, Text
|
||||
from sqlalchemy.orm import lazyload
|
||||
|
||||
from meshview import database
|
||||
from meshview import database, models
|
||||
from meshview.models import Node, Packet, PacketSeen, Traceroute
|
||||
|
||||
|
||||
@@ -24,27 +23,63 @@ async def get_fuzzy_nodes(query):
|
||||
return result.scalars()
|
||||
|
||||
|
||||
async def get_packets(node_id=None, portnum=None, after=None, before=None, limit=None):
|
||||
async def get_packets(
|
||||
from_node_id=None,
|
||||
to_node_id=None,
|
||||
node_id=None, # legacy
|
||||
portnum=None,
|
||||
after=None,
|
||||
contains=None, # substring search
|
||||
limit=50,
|
||||
):
|
||||
async with database.async_session() as session:
|
||||
q = select(Packet)
|
||||
stmt = select(models.Packet)
|
||||
conditions = []
|
||||
|
||||
if 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:
|
||||
q = q.where(Packet.import_time > after)
|
||||
if before:
|
||||
q = q.where(Packet.import_time < before)
|
||||
# Strict FROM filter
|
||||
if from_node_id is not None:
|
||||
conditions.append(models.Packet.from_node_id == from_node_id)
|
||||
|
||||
q = q.order_by(Packet.import_time.desc())
|
||||
# Strict TO filter
|
||||
if to_node_id is not None:
|
||||
conditions.append(models.Packet.to_node_id == to_node_id)
|
||||
|
||||
if limit is not None:
|
||||
q = q.limit(limit)
|
||||
# Legacy node_id (either direction)
|
||||
if node_id is not None:
|
||||
conditions.append(
|
||||
or_(
|
||||
models.Packet.from_node_id == node_id,
|
||||
models.Packet.to_node_id == node_id,
|
||||
)
|
||||
)
|
||||
|
||||
result = await session.execute(q)
|
||||
packets = list(result.scalars())
|
||||
return packets
|
||||
# Port filter
|
||||
if portnum is not None:
|
||||
conditions.append(models.Packet.portnum == portnum)
|
||||
|
||||
# Timestamp filter using microseconds
|
||||
if after is not None:
|
||||
conditions.append(models.Packet.import_time_us > after)
|
||||
|
||||
# Case-insensitive substring search on payload (BLOB → TEXT)
|
||||
if contains:
|
||||
contains_lower = f"%{contains.lower()}%"
|
||||
payload_text = cast(models.Packet.payload, Text)
|
||||
conditions.append(func.lower(payload_text).like(contains_lower))
|
||||
|
||||
# Apply WHERE conditions
|
||||
if conditions:
|
||||
stmt = stmt.where(and_(*conditions))
|
||||
|
||||
# Order by newest first
|
||||
stmt = stmt.order_by(models.Packet.import_time_us.desc())
|
||||
|
||||
# Limit
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
# Run query
|
||||
result = await session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
async def get_packets_from(node_id=None, portnum=None, since=None, limit=500):
|
||||
@@ -68,21 +103,6 @@ async def get_packet(packet_id):
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
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)
|
||||
)
|
||||
if portnum:
|
||||
q = q.where(Packet.portnum == portnum)
|
||||
result = await session.execute(q)
|
||||
return result.scalars()
|
||||
|
||||
|
||||
async def get_packets_seen(packet_id):
|
||||
async with database.async_session() as session:
|
||||
result = await session.execute(
|
||||
@@ -145,23 +165,6 @@ async def get_mqtt_neighbors(since):
|
||||
return result
|
||||
|
||||
|
||||
# We count the total amount of packages
|
||||
# This is to be used by /stats in web.py
|
||||
async def get_total_packet_count():
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(Packet.id)) # Use SQLAlchemy's func to count packets
|
||||
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:
|
||||
q = select(func.count(PacketSeen.node_id)) # Use SQLAlchemy's func to count nodes
|
||||
result = await session.execute(q)
|
||||
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:
|
||||
@@ -257,11 +260,12 @@ async def get_node_traffic(node_id: int):
|
||||
return []
|
||||
|
||||
|
||||
async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
|
||||
async def get_nodes(node_id=None, role=None, channel=None, hw_model=None, days_active=None):
|
||||
"""
|
||||
Fetches nodes from the database based on optional filtering criteria.
|
||||
|
||||
Parameters:
|
||||
node_id
|
||||
role (str, optional): The role of the node (converted to uppercase for consistency).
|
||||
channel (str, optional): The communication channel associated with the node.
|
||||
hw_model (str, optional): The hardware model of the node.
|
||||
@@ -277,6 +281,8 @@ async def get_nodes(role=None, channel=None, hw_model=None, days_active=None):
|
||||
query = select(Node)
|
||||
|
||||
# Apply filters based on provided parameters
|
||||
if node_id is not None:
|
||||
query = query.where(Node.node_id == node_id)
|
||||
if role is not None:
|
||||
query = query.where(Node.role == role.upper()) # Ensure role is uppercase
|
||||
if channel is not None:
|
||||
@@ -356,27 +362,155 @@ async def get_packet_stats(
|
||||
|
||||
async def get_channels_in_period(period_type: str = "hour", length: int = 24):
|
||||
"""
|
||||
Returns a list of distinct channels used in packets over a given period.
|
||||
Returns a sorted list of distinct channels used in packets over a given period.
|
||||
period_type: "hour" or "day"
|
||||
length: number of hours or days to look back
|
||||
"""
|
||||
now = datetime.now()
|
||||
now_us = int(datetime.utcnow().timestamp() * 1_000_000)
|
||||
|
||||
if period_type == "hour":
|
||||
start_time = now - timedelta(hours=length)
|
||||
delta_us = length * 3600 * 1_000_000
|
||||
elif period_type == "day":
|
||||
start_time = now - timedelta(days=length)
|
||||
delta_us = length * 86400 * 1_000_000
|
||||
else:
|
||||
raise ValueError("period_type must be 'hour' or 'day'")
|
||||
|
||||
start_us = now_us - delta_us
|
||||
|
||||
async with database.async_session() as session:
|
||||
q = (
|
||||
stmt = (
|
||||
select(Packet.channel)
|
||||
.where(Packet.import_time >= start_time)
|
||||
.where(Packet.import_time_us >= start_us)
|
||||
.distinct()
|
||||
.order_by(Packet.channel)
|
||||
)
|
||||
|
||||
result = await session.execute(q)
|
||||
channels = [row[0] for row in result if row[0] is not None]
|
||||
result = await session.execute(stmt)
|
||||
|
||||
channels = [ch for ch in result.scalars().all() if ch is not None]
|
||||
|
||||
return channels
|
||||
|
||||
|
||||
async def get_total_packet_count(
|
||||
period_type: str | None = None,
|
||||
length: int | None = None,
|
||||
channel: str | None = None,
|
||||
from_node: int | None = None,
|
||||
to_node: int | None = None,
|
||||
):
|
||||
"""
|
||||
Count total packets, with ALL filters optional.
|
||||
If no filters -> return ALL packets ever.
|
||||
Uses import_time_us (microseconds).
|
||||
"""
|
||||
|
||||
# CASE 1: no filters -> count everything
|
||||
if (
|
||||
period_type is None
|
||||
and length is None
|
||||
and channel is None
|
||||
and from_node is None
|
||||
and to_node is None
|
||||
):
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(Packet.id))
|
||||
res = await session.execute(q)
|
||||
return res.scalar() or 0
|
||||
|
||||
# CASE 2: filtered mode -> compute time window using import_time_us
|
||||
now_us = int(datetime.now().timestamp() * 1_000_000)
|
||||
|
||||
if period_type is None:
|
||||
period_type = "day"
|
||||
if length is None:
|
||||
length = 1
|
||||
|
||||
if period_type == "hour":
|
||||
start_time_us = now_us - (length * 3600 * 1_000_000)
|
||||
elif period_type == "day":
|
||||
start_time_us = now_us - (length * 86400 * 1_000_000)
|
||||
else:
|
||||
raise ValueError("period_type must be 'hour' or 'day'")
|
||||
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(Packet.id)).where(Packet.import_time_us >= start_time_us)
|
||||
|
||||
if channel:
|
||||
q = q.where(func.lower(Packet.channel) == channel.lower())
|
||||
if from_node:
|
||||
q = q.where(Packet.from_node_id == from_node)
|
||||
if to_node:
|
||||
q = q.where(Packet.to_node_id == to_node)
|
||||
|
||||
res = await session.execute(q)
|
||||
return res.scalar() or 0
|
||||
|
||||
|
||||
async def get_total_packet_seen_count(
|
||||
packet_id: int | None = None,
|
||||
period_type: str | None = None,
|
||||
length: int | None = None,
|
||||
channel: str | None = None,
|
||||
from_node: int | None = None,
|
||||
to_node: int | None = None,
|
||||
):
|
||||
"""
|
||||
Count total PacketSeen rows.
|
||||
- If packet_id is provided -> count only that packet's seen entries.
|
||||
- Otherwise match EXACT SAME FILTERS as get_total_packet_count.
|
||||
Uses import_time_us for time window.
|
||||
"""
|
||||
|
||||
# SPECIAL CASE: direct packet_id lookup
|
||||
if packet_id is not None:
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(PacketSeen.packet_id)).where(PacketSeen.packet_id == packet_id)
|
||||
res = await session.execute(q)
|
||||
return res.scalar() or 0
|
||||
|
||||
# No filters -> return ALL seen entries
|
||||
if (
|
||||
period_type is None
|
||||
and length is None
|
||||
and channel is None
|
||||
and from_node is None
|
||||
and to_node is None
|
||||
):
|
||||
async with database.async_session() as session:
|
||||
q = select(func.count(PacketSeen.packet_id))
|
||||
res = await session.execute(q)
|
||||
return res.scalar() or 0
|
||||
|
||||
# Compute time window
|
||||
now_us = int(datetime.now().timestamp() * 1_000_000)
|
||||
|
||||
if period_type is None:
|
||||
period_type = "day"
|
||||
if length is None:
|
||||
length = 1
|
||||
|
||||
if period_type == "hour":
|
||||
start_time_us = now_us - (length * 3600 * 1_000_000)
|
||||
elif period_type == "day":
|
||||
start_time_us = now_us - (length * 86400 * 1_000_000)
|
||||
else:
|
||||
raise ValueError("period_type must be 'hour' or 'day'")
|
||||
|
||||
# JOIN Packet so we can apply identical filters
|
||||
async with database.async_session() as session:
|
||||
q = (
|
||||
select(func.count(PacketSeen.packet_id))
|
||||
.join(Packet, Packet.id == PacketSeen.packet_id)
|
||||
.where(Packet.import_time_us >= start_time_us)
|
||||
)
|
||||
|
||||
if channel:
|
||||
q = q.where(func.lower(Packet.channel) == channel.lower())
|
||||
if from_node:
|
||||
q = q.where(Packet.from_node_id == from_node)
|
||||
if to_node:
|
||||
q = q.where(Packet.to_node_id == to_node)
|
||||
|
||||
res = await session.execute(q)
|
||||
return res.scalar() or 0
|
||||
|
||||
@@ -6,11 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Scripts -->
|
||||
<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" crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Stylesheets -->
|
||||
<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>
|
||||
@@ -25,181 +21,182 @@
|
||||
body.ready {
|
||||
opacity: 1;
|
||||
}
|
||||
.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>
|
||||
|
||||
<body>
|
||||
<br>
|
||||
<div style="text-align:center" id="site-header"></div>
|
||||
<div style="text-align:center" id="site-message"></div>
|
||||
<div style="text-align:center" id="site-menu"></div>
|
||||
<br>
|
||||
<div style="text-align:center" id="site-header"></div>
|
||||
<div style="text-align:center" id="site-message"></div>
|
||||
<div style="text-align:center" id="site-menu"></div>
|
||||
|
||||
<!-- 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>
|
||||
{% set options = {
|
||||
1: "Text Message",
|
||||
3: "Position",
|
||||
4: "Node Info",
|
||||
67: "Telemetry",
|
||||
70: "Traceroute",
|
||||
71: "Neighbor Info",
|
||||
}
|
||||
%}
|
||||
<select name="portnum" class="col-2 m-2">
|
||||
<option
|
||||
value = ""
|
||||
{% if portnum not in options %}selected{% endif %}
|
||||
>All</option>
|
||||
{% for value, name in options.items() %}
|
||||
<option
|
||||
value="{{value}}"
|
||||
{% if value == portnum %}selected{% endif %}
|
||||
>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="submit" value="Go to Node" class="col-2 m-2" data-translate-lang="go to node" />
|
||||
</div>
|
||||
</form>
|
||||
<br>
|
||||
|
||||
{% block body %}{% endblock %}
|
||||
{% block body %}{% endblock %}
|
||||
|
||||
<br>
|
||||
<div style="text-align:center" id="footer" data-translate-lang="footer"></div>
|
||||
<div style="text-align:center"><small id="site-version">ver. unknown</small></div>
|
||||
<br>
|
||||
<br>
|
||||
<div style="text-align:center" id="footer" data-translate="footer"></div>
|
||||
<div style="text-align:center"><small id="site-version">ver. unknown</small></div>
|
||||
<br>
|
||||
|
||||
<!-- Shared Site Config & Language Loader -->
|
||||
<script>
|
||||
// --- Global Promises ---
|
||||
if (!window._langPromise) {
|
||||
window._langPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/lang");
|
||||
const lang = await res.json();
|
||||
window._lang = lang;
|
||||
console.log("Loaded language from /api/lang:", lang);
|
||||
return lang;
|
||||
} catch (err) {
|
||||
console.error("Failed to load /api/lang:", err);
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (!window._siteConfigPromise) {
|
||||
window._siteConfigPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch("/api/config");
|
||||
const config = await res.json();
|
||||
window._siteConfig = config;
|
||||
console.log("Loaded config from /api/config:", config);
|
||||
return config;
|
||||
} catch (err) {
|
||||
console.error("Failed to load /api/config:", err);
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// --- Apply Translations ---
|
||||
function applyTranslations(lang) {
|
||||
if (!lang || !lang.base) return;
|
||||
const base = lang.base;
|
||||
|
||||
document.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
const translation = base[key];
|
||||
if (!translation) return;
|
||||
|
||||
if (el.placeholder) {
|
||||
el.placeholder = translation;
|
||||
} else if (key === "footer") {
|
||||
el.innerHTML = translation; // allow HTML links in footer
|
||||
} else if (el.value && el.tagName === "INPUT") {
|
||||
el.value = translation;
|
||||
} else {
|
||||
el.textContent = translation;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initializePage() {
|
||||
<script>
|
||||
// --- Shared Promises ---
|
||||
if (!window._siteConfigPromise) {
|
||||
window._siteConfigPromise = (async () => {
|
||||
try {
|
||||
const [lang, cfg] = await Promise.all([
|
||||
window._langPromise,
|
||||
window._siteConfigPromise
|
||||
]);
|
||||
const res = await fetch("/api/config");
|
||||
const cfg = await res.json();
|
||||
window._siteConfig = cfg;
|
||||
console.log("Loaded config:", cfg);
|
||||
return cfg;
|
||||
} catch (err) {
|
||||
console.error("Failed to load /api/config:", err);
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// --- Load language AFTER config ---
|
||||
if (!window._langPromise) {
|
||||
window._langPromise = (async () => {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const site = cfg.site || {};
|
||||
const base = (lang && lang.base) || {};
|
||||
const userLang = site.language || "en";
|
||||
const section = "base";
|
||||
|
||||
// --- Title ---
|
||||
document.title = "Meshview - " + (site.title || "");
|
||||
const url = `/api/lang?lang=${userLang}§ion=${section}`;
|
||||
const res = await fetch(url);
|
||||
const lang = await res.json();
|
||||
|
||||
// --- Header ---
|
||||
const header = document.getElementById("site-header");
|
||||
if (header)
|
||||
header.innerHTML = `<strong>${site.title || ""} ${site.domain ? "(" + site.domain + ")" : ""}</strong>`;
|
||||
window._lang = lang;
|
||||
console.log(`Loaded language (${userLang}):`, lang);
|
||||
|
||||
// --- Message ---
|
||||
const msg = document.getElementById("site-message");
|
||||
if (msg) msg.textContent = site.message || "";
|
||||
return lang;
|
||||
} catch (err) {
|
||||
console.error("Failed to load language:", err);
|
||||
return {};
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// --- Menu ---
|
||||
const menu = document.getElementById("site-menu");
|
||||
if (menu) {
|
||||
let html = "";
|
||||
if (site.nodes === "true") html += `<a href="/nodelist">${base.nodes || "Nodes"}</a>`;
|
||||
if (site.conversations === "true") html += ` - <a href="/chat">${base.conversations || "Conversations"}</a>`;
|
||||
if (site.everything === "true") html += ` - <a href="/firehose">${base.everything || "See Everything"}</a>`;
|
||||
if (site.graphs === "true") html += ` - <a href="/nodegraph">${base.graph || "Mesh Graphs"}</a>`;
|
||||
if (site.net === "true") html += ` - <a href="/net">${base.net || "Weekly Net"}</a>`;
|
||||
if (site.map === "true") html += ` - <a href="/map">${base.map || "Live Map"}</a>`;
|
||||
if (site.stats === "true") html += ` - <a href="/stats">${base.stats || "Stats"}</a>`;
|
||||
if (site.top === "true") html += ` - <a href="/top">${base.top || "Top Traffic Nodes"}</a>`;
|
||||
menu.innerHTML = html;
|
||||
// --- Translation Helper ---
|
||||
function applyTranslations(dict) {
|
||||
document.querySelectorAll("[data-translate]").forEach(el => {
|
||||
const key = el.dataset.translate;
|
||||
const value = dict[key];
|
||||
if (!value) return;
|
||||
|
||||
if (el.placeholder) {
|
||||
el.placeholder = value;
|
||||
} else if (el.tagName === "INPUT" && el.value) {
|
||||
el.value = value;
|
||||
} else if (key === "footer") {
|
||||
el.innerHTML = value;
|
||||
} else {
|
||||
el.textContent = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fill portnum select dynamically ---
|
||||
function fillPortnumSelect(dict, selectedValue) {
|
||||
const sel = document.getElementById("portnum_select");
|
||||
if (!sel) return;
|
||||
|
||||
const portOptions = dict.portnum_options || {};
|
||||
sel.innerHTML = "";
|
||||
|
||||
const allOption = document.createElement("option");
|
||||
allOption.value = "";
|
||||
allOption.textContent = dict.all || "All";
|
||||
if (!selectedValue) allOption.selected = true;
|
||||
sel.appendChild(allOption);
|
||||
|
||||
for (const [val, label] of Object.entries(portOptions)) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = val;
|
||||
opt.textContent = label;
|
||||
if (parseInt(val) === parseInt(selectedValue)) {
|
||||
opt.selected = true;
|
||||
}
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Main Init ---
|
||||
async function initializePage() {
|
||||
try {
|
||||
const [cfg, lang] = await Promise.all([
|
||||
window._siteConfigPromise,
|
||||
window._langPromise
|
||||
]);
|
||||
|
||||
const dict = lang || {};
|
||||
const site = cfg.site || {};
|
||||
|
||||
// Title
|
||||
document.title = "Meshview - " + (site.title || "");
|
||||
|
||||
// Header & Message
|
||||
document.getElementById("site-header").innerHTML =
|
||||
`<strong>${site.title || ""} ${site.domain ? "(" + site.domain + ")" : ""}</strong>`;
|
||||
|
||||
document.getElementById("site-message").textContent = site.message || "";
|
||||
|
||||
// Menu
|
||||
const menu = document.getElementById("site-menu");
|
||||
if (menu) {
|
||||
const items = [];
|
||||
const keys = ["nodes", "chat", "everything", "graphs", "net", "map", "stats", "top"];
|
||||
const urls = ["/nodelist", "/chat", "/firehose", "/nodegraph", "/net", "/map", "/stats", "/top"];
|
||||
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const key = keys[i];
|
||||
if (site[key] === "true") {
|
||||
items.push(`<a href="${urls[i]}">${dict[key] || key}</a>`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Version ---
|
||||
const verEl = document.getElementById("site-version");
|
||||
if (verEl) verEl.textContent = "ver. " + (site.version || "unknown");
|
||||
|
||||
// --- Apply translations to placeholders and footer ---
|
||||
applyTranslations(lang);
|
||||
|
||||
// --- Fade in ---
|
||||
document.body.classList.add("ready");
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize page:", err);
|
||||
document.body.classList.add("ready");
|
||||
menu.innerHTML = items.join(" - ");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initializePage);
|
||||
</script>
|
||||
// Version
|
||||
document.getElementById("site-version").textContent =
|
||||
"ver. " + (site.version || "unknown");
|
||||
|
||||
// Apply translations
|
||||
applyTranslations(dict);
|
||||
fillPortnumSelect(dict, "{{ portnum or '' }}");
|
||||
|
||||
document.body.classList.add("ready");
|
||||
} catch (err) {
|
||||
console.error("Failed to initialize page:", err);
|
||||
document.body.classList.add("ready");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initializePage);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<div id="buttons" class="btn-group" role="group">
|
||||
<a
|
||||
role="button"
|
||||
class="btn {{ 'btn-primary' if packet_event == 'packet' else 'btn-secondary'}}"
|
||||
href="/packet_list/{{node_id}}?{{query_string}}"
|
||||
>
|
||||
TX/RX
|
||||
</a>
|
||||
<a
|
||||
role="button"
|
||||
class="btn {{ 'btn-primary' if packet_event == 'uplinked' else 'btn-secondary'}}"
|
||||
href="/uplinked_list/{{node_id}}?{{query_string}}"
|
||||
>
|
||||
Uplinked
|
||||
</a>
|
||||
</div>
|
||||
@@ -1,20 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
.timestamp { min-width: 10em; }
|
||||
.timestamp {
|
||||
min-width: 10em;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
|
||||
.chat-packet { border-bottom: 1px solid #555; padding: 8px; border-radius: 8px; }
|
||||
.chat-packet {
|
||||
border-bottom: 1px solid #555;
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Same column spacing as before */
|
||||
.chat-packet > [class^="col-"] {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
padding-top: 1px !important;
|
||||
padding-bottom: 1px !important;
|
||||
}
|
||||
|
||||
.chat-packet:nth-of-type(even) { background-color: #333333; }
|
||||
|
||||
@keyframes flash { 0% { background-color: #ffe066; } 100% { background-color: inherit; } }
|
||||
.channel {
|
||||
font-style: italic;
|
||||
color: #bbb;
|
||||
}
|
||||
.channel a {
|
||||
font-style: normal;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% { background-color: #ffe066; }
|
||||
100% { background-color: inherit; }
|
||||
}
|
||||
.chat-packet.flash { animation: flash 3.5s ease-out; }
|
||||
|
||||
.replying-to { font-size: 0.85em; color: #aaa; margin-top: 4px; padding-left: 20px; }
|
||||
.replying-to {
|
||||
font-size: 0.8em;
|
||||
color: #aaa;
|
||||
margin-top: 2px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
.replying-to .reply-preview { color: #aaa; }
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="chat-container">
|
||||
<div id="chat-container" class="mt-3">
|
||||
|
||||
<!-- ⭐ CHAT TITLE WITH ICON, aligned to container ⭐ -->
|
||||
<div class="container px-2">
|
||||
<h2 style="color:white; margin:0 0 10px 0;">
|
||||
<span class="icon">💬</span>
|
||||
<span data-translate="chat_title"></span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div class="container" id="chat-log"></div>
|
||||
</div>
|
||||
|
||||
@@ -26,116 +70,175 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
let lastTime = null;
|
||||
const renderedPacketIds = new Set();
|
||||
const packetMap = new Map();
|
||||
let chatTranslations = {};
|
||||
let chatLang = {};
|
||||
|
||||
/* ==========================================================
|
||||
TRANSLATIONS FOR CHAT PAGE
|
||||
========================================================== */
|
||||
function applyTranslations(dict, root=document) {
|
||||
root.querySelectorAll("[data-translate]").forEach(el => {
|
||||
const key = el.dataset.translate;
|
||||
const val = dict[key];
|
||||
if (!val) return;
|
||||
if (el.placeholder) el.placeholder = val;
|
||||
else el.textContent = val;
|
||||
});
|
||||
}
|
||||
|
||||
async function loadChatLang() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const langCode = cfg?.site?.language || "en";
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=chat`);
|
||||
chatLang = await res.json();
|
||||
|
||||
// Apply to existing DOM
|
||||
applyTranslations(chatLang);
|
||||
} catch (err) {
|
||||
console.error("Chat translation load failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
SAFE HTML
|
||||
========================================================== */
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
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];
|
||||
});
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
RENDERING PACKETS
|
||||
========================================================== */
|
||||
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()}`;
|
||||
let date;
|
||||
if (packet.import_time_us && packet.import_time_us > 0) {
|
||||
date = new Date(packet.import_time_us / 1000);
|
||||
} else if (packet.import_time) {
|
||||
date = new Date(packet.import_time);
|
||||
} else {
|
||||
date = new Date();
|
||||
}
|
||||
|
||||
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);
|
||||
const replyPrefix = `<i data-translate="replying_to"></i>`;
|
||||
if (parent) {
|
||||
replyHtml = `<div class="replying-to">
|
||||
<div class="reply-preview">
|
||||
<i data-translate-lang="replying_to"></i>
|
||||
replyHtml = `
|
||||
<div class="replying-to">
|
||||
${replyPrefix}
|
||||
<strong>${escapeHtml((parent.long_name || "").trim() || `Node ${parent.from_node_id}`)}</strong>:
|
||||
${escapeHtml(parent.payload || "")}
|
||||
</div>
|
||||
</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>`;
|
||||
replyHtml = `
|
||||
<div class="replying-to">
|
||||
${replyPrefix}
|
||||
<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 timestamp" title="${packet.import_time_us}">
|
||||
${formattedTimestamp}
|
||||
</span>
|
||||
|
||||
<span class="col-2 channel">
|
||||
<a href="/packet/${packet.id}" data-translate-lang-title="view_packet_details">✉️</a>
|
||||
<a href="/packet/${packet.id}" title="${chatLang.view_packet_details || 'View details'}">🔎</a>
|
||||
${escapeHtml(packet.channel || "")}
|
||||
</span>
|
||||
|
||||
<span class="col-3 nodename">
|
||||
<a href="/packet_list/${packet.from_node_id}">
|
||||
<a href="/node/${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>
|
||||
|
||||
<span class="col-5 message">
|
||||
${escapeHtml(packet.payload)}${replyHtml}
|
||||
</span>
|
||||
`;
|
||||
|
||||
chatContainer.prepend(div);
|
||||
applyTranslations(chatTranslations, div);
|
||||
|
||||
// Translate newly added DOM
|
||||
applyTranslations(chatLang, 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);
|
||||
const sortedDesc = packets.slice().sort((a,b)=>{
|
||||
const aTime = a.import_time_us || (new Date(a.import_time).getTime() * 1000);
|
||||
const bTime = b.import_time_us || (new Date(b.import_time).getTime() * 1000);
|
||||
return bTime - aTime;
|
||||
});
|
||||
for (let i=sortedDesc.length-1; i>=0; i--) {
|
||||
renderPacket(sortedDesc[i], highlight);
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================
|
||||
FETCHING PACKETS
|
||||
========================================================== */
|
||||
async function fetchInitial() {
|
||||
try {
|
||||
const resp = await fetch("/api/chat?limit=100");
|
||||
const resp = await fetch("/api/packets?portnum=1&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("Initial fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUpdates() {
|
||||
try {
|
||||
const url = new URL("/api/chat", window.location.origin);
|
||||
const url = new URL("/api/packets?portnum=1", 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); }
|
||||
} catch(err){
|
||||
console.error("Fetch updates error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTranslations() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const langCode = cfg?.site?.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); }
|
||||
}
|
||||
/* ==========================================================
|
||||
INIT
|
||||
========================================================== */
|
||||
await loadChatLang(); // load translations FIRST
|
||||
await fetchInitial(); // then fetch initial packets
|
||||
|
||||
await loadTranslations();
|
||||
await fetchInitial();
|
||||
setInterval(fetchUpdates, 5000);
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
<datalist
|
||||
id="node_options"
|
||||
>
|
||||
{% for option in node_options %}
|
||||
<option value="{{option.id}}">{{option.id}} -- {{option.long_name}} ({{option.short_name}})</option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
{% block css %}
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 10px;
|
||||
}
|
||||
@@ -13,12 +12,23 @@
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.port-tag {
|
||||
display: inline-block;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Packet table */
|
||||
.packet-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.85rem;
|
||||
color: #e4e9ee;
|
||||
}
|
||||
|
||||
.packet-table th, .packet-table td {
|
||||
border: 1px solid #3a3f44;
|
||||
padding: 6px 10px;
|
||||
@@ -30,6 +40,8 @@
|
||||
}
|
||||
.packet-table tr:nth-of-type(odd) { background-color: #272b2f; }
|
||||
.packet-table tr:nth-of-type(even) { background-color: #212529; }
|
||||
|
||||
/* Port tag */
|
||||
.port-tag {
|
||||
display: inline-block;
|
||||
padding: 1px 6px;
|
||||
@@ -39,76 +51,132 @@
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* --- Color-coded port labels --- */
|
||||
.port-0 { background-color: #6c757d; }
|
||||
.port-1 { background-color: #007bff; }
|
||||
.port-3 { background-color: #28a745; }
|
||||
.port-4 { background-color: #ffc107; }
|
||||
.port-5 { background-color: #dc3545; }
|
||||
.port-6 { background-color: #20c997; }
|
||||
.port-65 { background-color: #ff66b3; }
|
||||
.port-67 { background-color: #17a2b8; }
|
||||
.port-70 { background-color: #6f42c1; }
|
||||
.port-71 { background-color: #fd7e14; }
|
||||
.to-mqtt { font-style: italic; color: #aaa; }
|
||||
|
||||
.to-mqtt {
|
||||
font-style: italic;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
/* --- Payload rows --- */
|
||||
.payload-row {
|
||||
display: none;
|
||||
background-color: #1b1e22;
|
||||
}
|
||||
/* Payload rows */
|
||||
.payload-row { display: none; background-color: #1b1e22; }
|
||||
.payload-cell {
|
||||
padding: 8px 12px;
|
||||
font-family: monospace;
|
||||
white-space: pre-wrap;
|
||||
color: #b0bec5;
|
||||
border-top: none;
|
||||
}
|
||||
.packet-table tr.expanded + .payload-row {
|
||||
display: table-row;
|
||||
}
|
||||
|
||||
/* Toggle arrow */
|
||||
.toggle-btn {
|
||||
cursor: pointer;
|
||||
color: #aaa;
|
||||
margin-right: 6px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.toggle-btn:hover {
|
||||
color: #fff;
|
||||
.toggle-btn:hover { color: #fff; }
|
||||
|
||||
/* Inline link next to port tag */
|
||||
.inline-link {
|
||||
margin-left: 6px;
|
||||
font-weight: bold;
|
||||
text-decoration: none;
|
||||
color: #9fd4ff;
|
||||
}
|
||||
.inline-link:hover {
|
||||
color: #c7e6ff;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
|
||||
<form class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h5 class="mb-0">📡 Live Feed</h5>
|
||||
<button type="button" id="pause-button" class="btn btn-sm btn-outline-secondary">Pause</button>
|
||||
<h2 class="mb-0" data-translate-lang="live_feed">📡 Live Feed</h2>
|
||||
|
||||
<button type="button"
|
||||
id="pause-button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
data-translate-lang="pause">
|
||||
Pause
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<table class="packet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Packet ID</th>
|
||||
<th>From</th>
|
||||
<th>To</th>
|
||||
<th>Port</th>
|
||||
<th>Links</th>
|
||||
<th data-translate-lang="time">Time</th>
|
||||
<th data-translate-lang="packet_id">Packet ID</th>
|
||||
<th data-translate-lang="from">From</th>
|
||||
<th data-translate-lang="to">To</th>
|
||||
<th data-translate-lang="port">Port</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="packet_list"></tbody>
|
||||
</table>
|
||||
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let lastImportTime = null;
|
||||
let updatesPaused = false;
|
||||
let nodeMap = {};
|
||||
let updateInterval = 3000;
|
||||
/* ======================================================
|
||||
FIREHOSE TRANSLATION SYSTEM (isolated from base)
|
||||
====================================================== */
|
||||
let firehoseTranslations = {};
|
||||
|
||||
function applyTranslationsFirehose(translations, root=document) {
|
||||
root
|
||||
.querySelectorAll("[data-translate-lang]")
|
||||
.forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if (!translations[key]) return;
|
||||
|
||||
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
|
||||
el.placeholder = translations[key];
|
||||
} else {
|
||||
el.textContent = translations[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function loadTranslationsFirehose() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const lang = cfg?.site?.language || "en";
|
||||
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=firehose`);
|
||||
firehoseTranslations = await res.json();
|
||||
|
||||
applyTranslationsFirehose(firehoseTranslations);
|
||||
} catch (err) {
|
||||
console.error("Firehose translation load failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
NODE LOOKUP
|
||||
====================================================== */
|
||||
let nodeMap = {};
|
||||
|
||||
async function loadNodes() {
|
||||
try {
|
||||
const res = await fetch("/api/nodes");
|
||||
const data = await res.json();
|
||||
|
||||
for (const n of data.nodes || []) {
|
||||
nodeMap[n.node_id] = n.long_name || n.short_name || n.id || n.node_id;
|
||||
}
|
||||
|
||||
nodeMap[4294967295] = firehoseTranslations.all_broadcast || "All";
|
||||
} catch (err) {
|
||||
console.error("Failed loading nodes:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function nodeName(id) {
|
||||
return nodeMap[id] || id;
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
PORT COLORS & NAMES
|
||||
====================================================== */
|
||||
const PORT_MAP = {
|
||||
0: "UNKNOWN APP",
|
||||
1: "Text Message",
|
||||
@@ -120,80 +188,88 @@ const PORT_MAP = {
|
||||
65: "Store Forward",
|
||||
67: "Telemetry",
|
||||
70: "Trace Route",
|
||||
71: "Neighbor Info",
|
||||
71: "Neighbor Info"
|
||||
};
|
||||
|
||||
const PORT_COLORS = {
|
||||
0: "#6c757d",
|
||||
1: "#007bff",
|
||||
3: "#28a745",
|
||||
4: "#ffc107",
|
||||
5: "#dc3545",
|
||||
6: "#20c997",
|
||||
65: "#6610f2",
|
||||
67: "#17a2b8",
|
||||
68: "#fd7e14",
|
||||
69: "#6f42c1",
|
||||
70: "#ff4444",
|
||||
71: "#ff66cc",
|
||||
72: "#00cc99",
|
||||
73: "#9999ff",
|
||||
74: "#cc00cc",
|
||||
75: "#ffbb33",
|
||||
76: "#00bcd4",
|
||||
77: "#8bc34a",
|
||||
78: "#795548"
|
||||
0: "#6c757d",
|
||||
1: "#007bff",
|
||||
3: "#28a745",
|
||||
4: "#ffc107",
|
||||
5: "#dc3545",
|
||||
6: "#20c997",
|
||||
65: "#6610f2",
|
||||
67: "#17a2b8",
|
||||
68: "#fd7e14",
|
||||
69: "#6f42c1",
|
||||
70: "#ff4444",
|
||||
71: "#ff66cc",
|
||||
72: "#00cc99",
|
||||
73: "#9999ff",
|
||||
74: "#cc00cc",
|
||||
75: "#ffbb33",
|
||||
76: "#00bcd4",
|
||||
77: "#8bc34a",
|
||||
78: "#795548"
|
||||
};
|
||||
|
||||
// --- Load node names ---
|
||||
async function loadNodes() {
|
||||
const res = await fetch("/api/nodes");
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
for (const n of data.nodes || []) {
|
||||
const name = n.long_name || n.short_name || n.id || n.node_id;
|
||||
nodeMap[n.node_id] = name;
|
||||
}
|
||||
nodeMap[4294967295] = "All";
|
||||
}
|
||||
|
||||
function nodeName(id) {
|
||||
if (id === 4294967295) return `<span class="to-mqtt">All</span>`;
|
||||
return nodeMap[id] || id;
|
||||
}
|
||||
|
||||
function portLabel(portnum, payload) {
|
||||
function portLabel(portnum, payload, linksHtml) {
|
||||
const name = PORT_MAP[portnum] || "Unknown";
|
||||
const color = PORT_COLORS[portnum] || "#6c757d";
|
||||
const safePayload = payload ? payload.replace(/"/g, """) : "";
|
||||
return `<span class="port-tag" style="background-color:${color}" title="${safePayload}">${name}</span>
|
||||
<span class="text-secondary">(${portnum})</span>`;
|
||||
|
||||
const safePayload = payload
|
||||
? payload.replace(/"/g, """)
|
||||
: "";
|
||||
|
||||
return `
|
||||
<span class="port-tag" style="background-color:${color}" title="${safePayload}">
|
||||
${name}
|
||||
</span>
|
||||
<span class="text-secondary">(${portnum})</span>
|
||||
${linksHtml || ""}
|
||||
`;
|
||||
}
|
||||
|
||||
// --- Fetch firehose interval from shared site config ---
|
||||
/* ======================================================
|
||||
TIME FORMAT
|
||||
====================================================== */
|
||||
function formatLocalTime(importTimeUs) {
|
||||
const ms = importTimeUs / 1000;
|
||||
return new Date(ms).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
FIREHOSE FETCHING
|
||||
====================================================== */
|
||||
let lastImportTimeUs = null;
|
||||
let updatesPaused = false;
|
||||
let updateInterval = 3000;
|
||||
|
||||
async function configureFirehose() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const intervalSec = cfg?.site?.firehose_interval;
|
||||
if (intervalSec && !isNaN(intervalSec)) {
|
||||
updateInterval = parseInt(intervalSec) * 1000;
|
||||
}
|
||||
console.log("Firehose update interval:", updateInterval, "ms");
|
||||
} catch (err) {
|
||||
console.warn("Failed to read firehose interval:", err);
|
||||
}
|
||||
const sec = cfg?.site?.firehose_interval;
|
||||
if (sec && !isNaN(sec)) updateInterval = sec * 1000;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// --- Fetch and render packets ---
|
||||
async function fetchUpdates() {
|
||||
if (updatesPaused) return;
|
||||
|
||||
const url = new URL("/api/packets", window.location.origin);
|
||||
if (lastImportTime) url.searchParams.set("since", lastImportTime);
|
||||
url.searchParams.set("limit", 50);
|
||||
|
||||
if (lastImportTimeUs)
|
||||
url.searchParams.set("since", lastImportTimeUs);
|
||||
|
||||
try {
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = await res.json();
|
||||
const packets = data.packets || [];
|
||||
if (!packets.length) return;
|
||||
@@ -201,79 +277,133 @@ async function fetchUpdates() {
|
||||
const list = document.getElementById("packet_list");
|
||||
|
||||
for (const pkt of packets.reverse()) {
|
||||
const fromNodeId = pkt.from_node_id;
|
||||
const toNodeId = pkt.to_node_id;
|
||||
|
||||
let from = fromNodeId === 4294967295 ? `<span class="to-mqtt">All</span>` :
|
||||
`<a href="/packet_list/${fromNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[fromNodeId] || fromNodeId}</a>`;
|
||||
/* FROM — includes translation */
|
||||
const from =
|
||||
pkt.from_node_id === 4294967295
|
||||
? `<span class="to-mqtt" data-translate-lang="all_broadcast">
|
||||
${firehoseTranslations.all_broadcast || "All"}
|
||||
</span>`
|
||||
: `<a href="/node/${pkt.from_node_id}" style="text-decoration:underline; color:inherit;">
|
||||
${nodeMap[pkt.from_node_id] || pkt.from_node_id}
|
||||
</a>`;
|
||||
|
||||
let to = toNodeId === 1 ? `<span class="to-mqtt">direct to MQTT</span>` :
|
||||
toNodeId === 4294967295 ? `<span class="to-mqtt">All</span>` :
|
||||
`<a href="/packet_list/${toNodeId}" style="text-decoration:underline; color:inherit;">${nodeMap[toNodeId] || toNodeId}</a>`;
|
||||
/* TO — includes translation */
|
||||
const to =
|
||||
pkt.to_node_id === 1
|
||||
? `<span class="to-mqtt" data-translate-lang="direct_to_mqtt">
|
||||
${firehoseTranslations.direct_to_mqtt || "direct to MQTT"}
|
||||
</span>`
|
||||
: pkt.to_node_id === 4294967295
|
||||
? `<span class="to-mqtt" data-translate-lang="all_broadcast">
|
||||
${firehoseTranslations.all_broadcast || "All"}
|
||||
</span>`
|
||||
: `<a href="/node/${pkt.to_node_id}" style="text-decoration:underline; color:inherit;">
|
||||
${nodeMap[pkt.to_node_id] || pkt.to_node_id}
|
||||
</a>`;
|
||||
|
||||
let links = "";
|
||||
let inlineLinks = "";
|
||||
|
||||
// Position link
|
||||
if (pkt.portnum === 3 && pkt.payload) {
|
||||
const latMatch = pkt.payload.match(/latitude_i:\s*(-?\d+)/);
|
||||
const lonMatch = pkt.payload.match(/longitude_i:\s*(-?\d+)/);
|
||||
|
||||
if (latMatch && lonMatch) {
|
||||
const lat = parseInt(latMatch[1]) / 1e7;
|
||||
const lon = parseInt(lonMatch[1]) / 1e7;
|
||||
links += `<a href="https://www.google.com/maps?q=${lat},${lon}" target="_blank" rel="noopener noreferrer" style="font-weight:bold; text-decoration:none;">Map</a>`;
|
||||
inlineLinks += ` <a class="inline-link"
|
||||
href="https://www.google.com/maps?q=${lat},${lon}"
|
||||
target="_blank">📍</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Traceroute link
|
||||
if (pkt.portnum === 70) {
|
||||
let traceId = pkt.id;
|
||||
const idMatch = pkt.payload.match(/ID:\s*(\d+)/i);
|
||||
if (idMatch) traceId = idMatch[1];
|
||||
if (links) links += " | ";
|
||||
links += `<a href="/graph/traceroute/${traceId}" target="_blank" rel="noopener noreferrer" style="font-weight:bold; text-decoration:none;">Graph</a>`;
|
||||
const match = pkt.payload.match(/ID:\s*(\d+)/i);
|
||||
if (match) traceId = match[1];
|
||||
|
||||
inlineLinks += ` <a class="inline-link"
|
||||
href="/graph/traceroute/${traceId}"
|
||||
target="_blank">⮕</a>`;
|
||||
}
|
||||
|
||||
const safePayload = (pkt.payload || "").replace(/</g, "<").replace(/>/g, ">");
|
||||
const safePayload = (pkt.payload || "")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
const html = `
|
||||
<tr class="packet-row" data-id="${pkt.id}">
|
||||
<td><span class="toggle-btn">▶</span> <a href="/packet/${pkt.id}" style="text-decoration:underline; color:inherit;">${pkt.id}</a></td>
|
||||
<tr class="packet-row">
|
||||
|
||||
<td>${formatLocalTime(pkt.import_time_us)}</td>
|
||||
|
||||
<td>
|
||||
<span class="toggle-btn">▶</span>
|
||||
<a href="/packet/${pkt.id}"
|
||||
style="text-decoration:underline; color:inherit;">
|
||||
${pkt.id}
|
||||
</a>
|
||||
</td>
|
||||
|
||||
<td>${from}</td>
|
||||
<td>${to}</td>
|
||||
<td>${portLabel(pkt.portnum, pkt.payload)}</td>
|
||||
<td>${links}</td>
|
||||
<td>${portLabel(pkt.portnum, pkt.payload, inlineLinks)}</td>
|
||||
|
||||
</tr>
|
||||
|
||||
<tr class="payload-row">
|
||||
<td colspan="5" class="payload-cell">${safePayload}</td>
|
||||
</tr>`;
|
||||
</tr>
|
||||
`;
|
||||
|
||||
list.insertAdjacentHTML("afterbegin", html);
|
||||
}
|
||||
|
||||
// Limit table size
|
||||
while (list.rows.length > 400) list.deleteRow(-1);
|
||||
lastImportTime = packets[packets.length - 1].import_time;
|
||||
|
||||
lastImportTimeUs = packets[packets.length - 1].import_time_us;
|
||||
|
||||
} catch (err) {
|
||||
console.error("Packet fetch failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialize ---
|
||||
/* ======================================================
|
||||
INITIALIZE PAGE
|
||||
====================================================== */
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
const pauseBtn = document.getElementById("pause-button");
|
||||
|
||||
pauseBtn.addEventListener("click", () => {
|
||||
updatesPaused = !updatesPaused;
|
||||
pauseBtn.textContent = updatesPaused ? "Resume" : "Pause";
|
||||
|
||||
pauseBtn.textContent =
|
||||
updatesPaused
|
||||
? (firehoseTranslations.resume || "Resume")
|
||||
: (firehoseTranslations.pause || "Pause");
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
document.addEventListener("click", e => {
|
||||
const btn = e.target.closest(".toggle-btn");
|
||||
if (!btn) return;
|
||||
|
||||
const row = btn.closest(".packet-row");
|
||||
row.classList.toggle("expanded");
|
||||
btn.textContent = row.classList.contains("expanded") ? "▼" : "▶";
|
||||
|
||||
btn.textContent =
|
||||
row.classList.contains("expanded") ? "▼" : "▶";
|
||||
});
|
||||
|
||||
await loadTranslationsFirehose();
|
||||
await configureFirehose();
|
||||
await loadNodes();
|
||||
|
||||
fetchUpdates();
|
||||
setInterval(fetchUpdates, updateInterval);
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -7,78 +7,200 @@
|
||||
<style>
|
||||
.legend { background:white;padding:8px;line-height:1.5;border-radius:5px;box-shadow:0 0 10px rgba(0,0,0,0.3);font-size:14px;color:black; }
|
||||
.legend i { width:12px;height:12px;display:inline-block;margin-right:6px;border-radius:50%; }
|
||||
|
||||
#filter-container { text-align:center;margin-top:10px; }
|
||||
.filter-checkbox { margin:0 10px; }
|
||||
#share-button, #reset-filters-button {
|
||||
|
||||
#share-button,
|
||||
#reset-filters-button {
|
||||
padding:5px 15px;border:none;border-radius:4px;font-size:14px;cursor:pointer;color:white;
|
||||
}
|
||||
#share-button { margin-left:20px; background-color:#4CAF50; }
|
||||
#share-button:hover { background-color:#45a049; }
|
||||
#share-button:active { background-color:#3d8b40; }
|
||||
|
||||
#reset-filters-button { margin-left:10px; background-color:#f44336; }
|
||||
#reset-filters-button:hover { background-color:#da190b; }
|
||||
#reset-filters-button:active { background-color:#c41e0d; }
|
||||
|
||||
.blinking-tooltip { background:white;color:black;border:1px solid black;border-radius:4px;padding:2px 5px; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% 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
|
||||
|
||||
<div id="map" style="width:100%; height:calc(100vh - 270px)"></div>
|
||||
|
||||
<div id="map-legend"
|
||||
class="legend"
|
||||
style="position:absolute;
|
||||
bottom:30px;
|
||||
right:15px;
|
||||
z-index:500;
|
||||
pointer-events:none;">
|
||||
<div>
|
||||
<i style="background:orange; width:15px; height:3px; border-radius:0;"></i>
|
||||
<span data-translate-lang="legend_traceroute">Traceroute Path (arrowed)</span>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:6px;">
|
||||
<i style="background:gray; width:15px; height:3px; border-radius:0;"></i>
|
||||
<span data-translate-lang="legend_neighbor">Neighbor Link</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="filter-container">
|
||||
<input type="checkbox" class="filter-checkbox" id="filter-routers-only">
|
||||
<span data-translate-lang="show_routers_only">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="reset-filters-button" onclick="resetFiltersToDefaults()">↺ Reset Filters To Defaults</button>
|
||||
<button id="share-button" onclick="shareCurrentView()" data-translate-lang="share_view">
|
||||
🔗 Share This View
|
||||
</button>
|
||||
<button id="reset-filters-button" onclick="resetFiltersToDefaults()" data-translate-lang="reset_filters">
|
||||
↺ Reset Filters To Defaults
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
|
||||
<script src="https://unpkg.com/leaflet-polylinedecorator@1.6.0/dist/leaflet.polylinedecorator.js"
|
||||
integrity="sha384-FhPn/2P/fJGhQLeNWDn9B/2Gml2bPOrKJwFqJXgR3xOPYxWg5mYQ5XZdhUSugZT0"
|
||||
crossorigin></script>
|
||||
|
||||
<script>
|
||||
// ---------------------- Map Initialization ----------------------
|
||||
var map = L.map('map');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom:19, attribution:'© OpenStreetMap' }).addTo(map);
|
||||
/* ======================================================
|
||||
MAP PAGE TRANSLATION SYSTEM
|
||||
====================================================== */
|
||||
|
||||
// ---------------------- Globals ----------------------
|
||||
var nodes=[], markers={}, markerById={}, nodeMap = new Map();
|
||||
var edgesData=[], edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
|
||||
let mapTranslations = {};
|
||||
|
||||
async function loadTranslationsMap() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const lang = cfg?.site?.language || "en";
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=map`);
|
||||
mapTranslations = await res.json();
|
||||
applyTranslationsMap();
|
||||
} catch (err) {
|
||||
console.error("Map translation load failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTranslationsMap(root = document) {
|
||||
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
const val = mapTranslations[key];
|
||||
if (!val) return;
|
||||
|
||||
if (el.tagName === "INPUT" && el.placeholder !== undefined) {
|
||||
el.placeholder = val;
|
||||
} else {
|
||||
el.textContent = val;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
EXISTING MAP LOGIC
|
||||
====================================================== */
|
||||
|
||||
var map = L.map('map');
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
{ maxZoom:19, attribution:'© OpenStreetMap' }).addTo(map);
|
||||
|
||||
// Data structures
|
||||
var nodes = [], markers = {}, markerById = {}, nodeMap = new Map();
|
||||
var edgeLayer = L.layerGroup().addTo(map), selectedNodeId = null;
|
||||
var activeBlinks = new Map(), lastImportTime = null;
|
||||
var mapInterval = 0;
|
||||
const portMap = {1:"Text",67:"Telemetry",3:"Position",70:"Traceroute",4:"Node Info",71:"Neighbour Info",73:"Map Report"};
|
||||
const palette = ["#e6194b","#4363d8","#f58231","#911eb4","#46f0f0","#f032e6","#bcf60c","#fabebe","#008080","#e6beff","#9a6324","#fffac8","#800000","#aaffc3","#808000","#ffd8b1","#000075","#808080"];
|
||||
|
||||
const portMap = {
|
||||
1:"Text",
|
||||
67:"Telemetry",
|
||||
3:"Position",
|
||||
70:"Traceroute",
|
||||
4:"Node Info",
|
||||
71:"Neighbour Info",
|
||||
73:"Map Report"
|
||||
};
|
||||
|
||||
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;
|
||||
const channelSet = new Set();
|
||||
|
||||
// ---------------------- Helpers ----------------------
|
||||
function timeAgo(date){ const diff=Date.now()-new Date(date), s=Math.floor(diff/1000), m=Math.floor(s/60), h=Math.floor(m/60), d=Math.floor(h/24); return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s"; }
|
||||
function hashToColor(str){ if(colorMap.has(str)) return colorMap.get(str); const c=palette[nextColorIndex++%palette.length]; colorMap.set(str,c); return c; }
|
||||
function isInvalidCoord(n){ return !n||!n.lat||!n.long||n.lat===0||n.long===0||Number.isNaN(n.lat)||Number.isNaN(n.long); }
|
||||
map.on("popupopen", function (e) {
|
||||
const popupEl = e.popup.getElement();
|
||||
if (popupEl) applyTranslationsMap(popupEl);
|
||||
});
|
||||
|
||||
// ---------------------- Packet Fetching ----------------------
|
||||
function fetchLatestPacket(){ fetch(`/api/packets?limit=1`).then(r=>r.json()).then(data=>{ lastImportTime=data.packets?.[0]?.import_time||new Date().toISOString(); }).catch(console.error); }
|
||||
function fetchNewPackets(){
|
||||
if(mapInterval <= 0) return;
|
||||
if(!lastImportTime) return;
|
||||
fetch(`/api/packets?since=${encodeURIComponent(lastImportTime)}`).then(r=>r.json()).then(data=>{
|
||||
if(!data.packets||data.packets.length===0) return;
|
||||
let latest = lastImportTime;
|
||||
data.packets.forEach(pkt=>{
|
||||
if(pkt.import_time>latest) latest=pkt.import_time;
|
||||
const marker = markerById[pkt.from_node_id];
|
||||
const nodeData = nodeMap.get(pkt.from_node_id);
|
||||
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
|
||||
});
|
||||
lastImportTime=latest;
|
||||
}).catch(console.error);
|
||||
function timeAgo(date){
|
||||
const diff = Date.now() - new Date(date);
|
||||
const s = Math.floor(diff/1000), m = Math.floor(s/60),
|
||||
h = Math.floor(m/60), d = Math.floor(h/24);
|
||||
return d>0?d+"d":h>0?h+"h":m>0?m+"m":s+"s";
|
||||
}
|
||||
|
||||
function hashToColor(str){
|
||||
if(colorMap.has(str)) return colorMap.get(str);
|
||||
const c = palette[nextColorIndex++ % palette.length];
|
||||
colorMap.set(str,c);
|
||||
return c;
|
||||
}
|
||||
|
||||
function isInvalidCoord(n){
|
||||
return !n || !n.lat || !n.long || n.lat === 0 || n.long === 0 ||
|
||||
Number.isNaN(n.lat) || Number.isNaN(n.long);
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
PACKET FETCHING (unchanged)
|
||||
====================================================== */
|
||||
|
||||
function fetchLatestPacket(){
|
||||
fetch(`/api/packets?limit=1`)
|
||||
.then(r=>r.json())
|
||||
.then(data=>{
|
||||
lastImportTime=data.packets?.[0]?.import_time_us||0;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
function fetchNewPackets(){
|
||||
if(mapInterval <= 0) return;
|
||||
if(lastImportTime===null) return;
|
||||
|
||||
const url = new URL(`/api/packets`, window.location.origin);
|
||||
url.searchParams.set("since", lastImportTime);
|
||||
url.searchParams.set("limit", 50);
|
||||
|
||||
fetch(url)
|
||||
.then(r=>r.json())
|
||||
.then(data=>{
|
||||
if(!data.packets || data.packets.length===0) return;
|
||||
let latest = lastImportTime;
|
||||
|
||||
data.packets.forEach(pkt=>{
|
||||
if(pkt.import_time_us > latest) latest = pkt.import_time_us;
|
||||
|
||||
const marker = markerById[pkt.from_node_id];
|
||||
const nodeData = nodeMap.get(pkt.from_node_id);
|
||||
if(marker && nodeData) blinkNode(marker,nodeData.long_name,pkt.portnum);
|
||||
});
|
||||
|
||||
lastImportTime = latest;
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
// ---------------------- Polling ----------------------
|
||||
let packetInterval=null;
|
||||
|
||||
function startPacketFetcher(){
|
||||
if(mapInterval<=0) return;
|
||||
if(!packetInterval){
|
||||
@@ -86,65 +208,58 @@ function startPacketFetcher(){
|
||||
packetInterval=setInterval(fetchNewPackets,mapInterval*1000);
|
||||
}
|
||||
}
|
||||
|
||||
function stopPacketFetcher(){
|
||||
if(packetInterval){
|
||||
clearInterval(packetInterval);
|
||||
packetInterval=null;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("visibilitychange",()=>{
|
||||
document.hidden?stopPacketFetcher():startPacketFetcher();
|
||||
});
|
||||
|
||||
// ---------------------- WAIT FOR CONFIG ----------------------
|
||||
async function waitForConfig() {
|
||||
while (typeof window._siteConfigPromise === "undefined") {
|
||||
console.log("Waiting for _siteConfigPromise...");
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
if (!cfg || !cfg.site) throw new Error("Config missing site object");
|
||||
return cfg.site;
|
||||
return cfg.site || {};
|
||||
} catch (err) {
|
||||
console.error("Error loading site config:", err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Load Config & Start Polling ----------------------
|
||||
async function initMapPolling() {
|
||||
try {
|
||||
const site = await waitForConfig();
|
||||
mapInterval = parseInt(site.map_interval, 10) || 0;
|
||||
|
||||
// ---- Check URL params ----
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const lat = parseFloat(params.get('lat'));
|
||||
const lng = parseFloat(params.get('lng'));
|
||||
const lat = parseFloat(params.get('lat'));
|
||||
const lng = parseFloat(params.get('lng'));
|
||||
const zoom = parseInt(params.get('zoom'), 10);
|
||||
|
||||
if (!isNaN(lat) && !isNaN(lng) && !isNaN(zoom)) {
|
||||
map.setView([lat, lng], zoom);
|
||||
window.configBoundsApplied = true;
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
}
|
||||
else {
|
||||
const topLeft = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
|
||||
const bottomRight = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
|
||||
if (topLeft.every(isFinite) && bottomRight.every(isFinite)) {
|
||||
map.fitBounds([topLeft, bottomRight]);
|
||||
const tl = [parseFloat(site.map_top_left_lat), parseFloat(site.map_top_left_lon)];
|
||||
const br = [parseFloat(site.map_bottom_right_lat), parseFloat(site.map_bottom_right_lon)];
|
||||
|
||||
if (tl.every(isFinite) && br.every(isFinite)) {
|
||||
map.fitBounds([tl, br]);
|
||||
window.configBoundsApplied = true;
|
||||
setTimeout(() => map.invalidateSize(), 100);
|
||||
}
|
||||
}
|
||||
|
||||
if (mapInterval > 0) {
|
||||
console.log(`Starting map polling every ${mapInterval}s`);
|
||||
startPacketFetcher();
|
||||
} else {
|
||||
console.log("Map polling disabled (map_interval=0)");
|
||||
}
|
||||
if (mapInterval > 0) startPacketFetcher();
|
||||
|
||||
} catch (err) {
|
||||
console.error("Failed to load /api/config:", err);
|
||||
@@ -153,116 +268,222 @@ async function initMapPolling() {
|
||||
|
||||
initMapPolling();
|
||||
|
||||
// ---------------------- Load Nodes + Edges ----------------------
|
||||
fetch('/api/nodes?days_active=3').then(r=>r.json()).then(data=>{
|
||||
if(!data.nodes) return;
|
||||
nodes = data.nodes.map(n=>({
|
||||
key: n.node_id!==null?n.node_id:n.id,
|
||||
id: n.id,
|
||||
node_id: n.node_id,
|
||||
lat: n.last_lat?n.last_lat/1e7:null,
|
||||
long: n.last_long?n.last_long/1e7:null,
|
||||
long_name: n.long_name||"",
|
||||
short_name: n.short_name||"",
|
||||
channel: n.channel||"",
|
||||
hw_model: n.hw_model||"",
|
||||
role: n.role||"",
|
||||
firmware: n.firmware||"",
|
||||
last_update: n.last_update||"",
|
||||
isRouter: n.role? n.role.toLowerCase().includes("router"):false
|
||||
}));
|
||||
nodes.forEach(n=>{ nodeMap.set(n.key,n); if(n.channel) channelSet.add(n.channel); });
|
||||
renderNodesOnMap();
|
||||
createChannelFilters();
|
||||
return fetch('/api/edges');
|
||||
}).then(r=>r?r.json():null).then(data=>{
|
||||
if(data && data.edges) edgesData=data.edges;
|
||||
}).catch(console.error);
|
||||
/* ======================================================
|
||||
LOAD NODES
|
||||
====================================================== */
|
||||
|
||||
fetch('/api/nodes?days_active=3')
|
||||
.then(r=>r.json())
|
||||
.then(data=>{
|
||||
if(!data.nodes) return;
|
||||
|
||||
nodes = data.nodes.map(n=>({
|
||||
key: n.node_id ?? n.id,
|
||||
id: n.id,
|
||||
node_id: n.node_id,
|
||||
lat: n.last_lat ? n.last_lat/1e7 : null,
|
||||
long: n.last_long ? n.last_long/1e7 : null,
|
||||
long_name: n.long_name || "",
|
||||
short_name: n.short_name || "",
|
||||
channel: n.channel || "",
|
||||
hw_model: n.hw_model || "",
|
||||
role: n.role || "",
|
||||
firmware: n.firmware || "",
|
||||
last_update: n.last_update || "",
|
||||
isRouter: (n.role||"").toLowerCase().includes("router")
|
||||
}));
|
||||
|
||||
nodes.forEach(n=>{
|
||||
nodeMap.set(n.key, n);
|
||||
if(n.channel) channelSet.add(n.channel);
|
||||
});
|
||||
|
||||
renderNodesOnMap();
|
||||
createChannelFilters();
|
||||
})
|
||||
.catch(console.error);
|
||||
|
||||
/* ======================================================
|
||||
RENDER NODES
|
||||
====================================================== */
|
||||
|
||||
// ---------------------- Render Nodes ----------------------
|
||||
function renderNodesOnMap(){
|
||||
const bounds = L.latLngBounds();
|
||||
nodes.forEach(node=>{
|
||||
if(isInvalidCoord(node)) return;
|
||||
|
||||
const color = hashToColor(node.channel);
|
||||
const opts = { radius: node.isRouter?9:7, color:"white", fillColor:color, fillOpacity:1, weight:0.7 };
|
||||
const marker = L.circleMarker([node.lat,node.long],opts).addTo(map);
|
||||
|
||||
const 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.key;
|
||||
marker.originalColor = color;
|
||||
markerById[node.key] = marker;
|
||||
const popup = `<b><a href="/packet_list/${node.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>
|
||||
${node.last_update? `<b>Last seen:</b> ${timeAgo(node.last_update)}<br>`:""}
|
||||
${node.firmware? `<b>Firmware:</b> ${node.firmware}<br>`:""}`;
|
||||
marker.on('click',()=>{ onNodeClick(node); marker.bindPopup(popup).openPopup(); setTimeout(()=>marker.closePopup(),3000); });
|
||||
bounds.extend(marker.getLatLng());
|
||||
|
||||
const popup = `
|
||||
<b><a href="/node/${node.node_id}">${node.long_name}</a> (${node.short_name})</b><br>
|
||||
|
||||
<b data-translate-lang="channel_label"></b> ${node.channel}<br>
|
||||
<b data-translate-lang="model_label"></b> ${node.hw_model}<br>
|
||||
<b data-translate-lang="role_label"></b> ${node.role}<br>
|
||||
|
||||
${
|
||||
node.last_update
|
||||
? `<b data-translate-lang="last_seen"></b> ${timeAgo(node.last_update)}<br>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
node.firmware
|
||||
? `<b data-translate-lang="firmware"></b> ${node.firmware}<br>`
|
||||
: ""
|
||||
}
|
||||
`;
|
||||
|
||||
marker.on('click', () => {
|
||||
onNodeClick(node);
|
||||
marker.bindPopup(popup).openPopup();
|
||||
});
|
||||
});
|
||||
if(!window.configBoundsApplied && bounds.isValid()){
|
||||
map.fitBounds(bounds);
|
||||
setTimeout(()=>map.invalidateSize(),100);
|
||||
|
||||
setTimeout(() => applyTranslationsMap(), 50);
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
⭐ NEW: DYNAMIC EDGE LOADING
|
||||
====================================================== */
|
||||
|
||||
async function onNodeClick(node){
|
||||
selectedNodeId = node.key;
|
||||
edgeLayer.clearLayers();
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/edges?node_id=${node.key}`);
|
||||
const data = await res.json();
|
||||
const edges = data.edges || [];
|
||||
|
||||
edges.forEach(edge=>{
|
||||
const f = nodeMap.get(edge.from);
|
||||
const t = nodeMap.get(edge.to);
|
||||
|
||||
if(!f || !t || isInvalidCoord(f) || isInvalidCoord(t)) return;
|
||||
|
||||
const color = edge.type === "neighbor" ? "gray" : "orange";
|
||||
const line = L.polyline([[f.lat, f.long], [t.lat, t.long]], {
|
||||
color, weight: 3
|
||||
}).addTo(edgeLayer);
|
||||
|
||||
if(edge.type === "traceroute"){
|
||||
L.polylineDecorator(line, {
|
||||
patterns: [
|
||||
{
|
||||
offset: '100%',
|
||||
repeat: 0,
|
||||
symbol: L.Symbol.arrowHead({
|
||||
pixelSize:5,
|
||||
polygon:false,
|
||||
pathOptions:{stroke:true,color}
|
||||
})
|
||||
}
|
||||
]
|
||||
}).addTo(edgeLayer);
|
||||
}
|
||||
});
|
||||
|
||||
} catch(err){
|
||||
console.error("Failed to load edges for node", node.key, err);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------- Render Edges ----------------------
|
||||
function onNodeClick(node){
|
||||
selectedNodeId = node.key;
|
||||
edgeLayer.clearLayers();
|
||||
edgesData.forEach(edge=>{
|
||||
if(edge.from!==node.key && edge.to!==node.key) return;
|
||||
const f=nodeMap.get(edge.from), t=nodeMap.get(edge.to);
|
||||
if(!f||!t||isInvalidCoord(f)||isInvalidCoord(t)) return;
|
||||
const color=edge.type==="neighbor"?"gray":"orange";
|
||||
const l=L.polyline([[f.lat,f.long],[t.lat,t.long]],{color,weight:3}).addTo(edgeLayer);
|
||||
if(edge.type==="traceroute"){
|
||||
L.polylineDecorator(l,{patterns:[{offset:'100%',repeat:0,symbol:L.Symbol.arrowHead({pixelSize:5,polygon:false,pathOptions:{stroke:true,color}})}]}).addTo(edgeLayer);
|
||||
}
|
||||
});
|
||||
}
|
||||
map.on('click',e=>{ if(!e.originalEvent.target.classList.contains('leaflet-interactive')){ edgeLayer.clearLayers(); selectedNodeId=null; } });
|
||||
map.on('click', e=>{
|
||||
if(!e.originalEvent.target.classList.contains('leaflet-interactive')){
|
||||
edgeLayer.clearLayers();
|
||||
selectedNodeId=null;
|
||||
}
|
||||
});
|
||||
|
||||
/* ======================================================
|
||||
BLINKING
|
||||
====================================================== */
|
||||
|
||||
// ---------------------- 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 blinkCount=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()).addTo(map);
|
||||
|
||||
if(activeBlinks.has(marker)){
|
||||
clearInterval(activeBlinks.get(marker));
|
||||
marker.setStyle({ fillColor: marker.originalColor });
|
||||
if(marker.tooltip) map.removeLayer(marker.tooltip);
|
||||
}
|
||||
|
||||
let blinkCount = 0;
|
||||
const tooltip = L.tooltip({
|
||||
permanent:true,
|
||||
direction:'top',
|
||||
offset:[0,-marker.options.radius-5],
|
||||
className:'blinking-tooltip'
|
||||
})
|
||||
.setContent(`${longName} (${portMap[portnum] || "Port "+portnum})`)
|
||||
.setLatLng(marker.getLatLng())
|
||||
.addTo(map);
|
||||
|
||||
marker.tooltip = tooltip;
|
||||
|
||||
const interval = setInterval(()=>{
|
||||
if(map.hasLayer(marker)){ marker.setStyle({fillColor: blinkCount%2===0?'yellow':marker.originalColor}); marker.bringToFront(); }
|
||||
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); }
|
||||
|
||||
if(blinkCount>7){
|
||||
clearInterval(interval);
|
||||
marker.setStyle({ fillColor: marker.originalColor });
|
||||
map.removeLayer(tooltip);
|
||||
activeBlinks.delete(marker);
|
||||
}
|
||||
|
||||
},500);
|
||||
activeBlinks.set(marker,interval);
|
||||
|
||||
activeBlinks.set(marker, interval);
|
||||
}
|
||||
|
||||
// ---------------------- Channel Filters ----------------------
|
||||
/* ======================================================
|
||||
CHANNEL FILTERS
|
||||
====================================================== */
|
||||
|
||||
function createChannelFilters(){
|
||||
const filterContainer = document.getElementById("filter-container");
|
||||
const savedState = JSON.parse(localStorage.getItem("mapFilters") || "{}");
|
||||
const saved = JSON.parse(localStorage.getItem("mapFilters") || "{}");
|
||||
|
||||
channelSet.forEach(channel=>{
|
||||
const checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "filter-checkbox";
|
||||
checkbox.id = `filter-channel-${channel}`;
|
||||
checkbox.checked = savedState[channel] !== false;
|
||||
checkbox.addEventListener("change", saveFiltersToLocalStorage);
|
||||
checkbox.addEventListener("change", updateNodeVisibility);
|
||||
filterContainer.appendChild(checkbox);
|
||||
const cb=document.createElement("input");
|
||||
cb.type="checkbox";
|
||||
cb.className="filter-checkbox";
|
||||
cb.id=`filter-channel-${channel}`;
|
||||
cb.checked = saved[channel] !== false;
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.htmlFor = checkbox.id;
|
||||
label.innerText = channel;
|
||||
cb.addEventListener("change", saveFiltersToLocalStorage);
|
||||
cb.addEventListener("change", updateNodeVisibility);
|
||||
|
||||
filterContainer.appendChild(cb);
|
||||
|
||||
const label=document.createElement("label");
|
||||
label.htmlFor=cb.id;
|
||||
label.innerText=channel;
|
||||
label.style.color = hashToColor(channel);
|
||||
filterContainer.appendChild(label);
|
||||
});
|
||||
|
||||
const routerOnly = document.getElementById("filter-routers-only");
|
||||
routerOnly.checked = savedState["routersOnly"] || false;
|
||||
const routerOnly=document.getElementById("filter-routers-only");
|
||||
routerOnly.checked = saved["routersOnly"] || false;
|
||||
|
||||
routerOnly.addEventListener("change", saveFiltersToLocalStorage);
|
||||
routerOnly.addEventListener("change", updateNodeVisibility);
|
||||
|
||||
@@ -271,51 +492,69 @@ function createChannelFilters(){
|
||||
|
||||
function saveFiltersToLocalStorage(){
|
||||
const state = {};
|
||||
channelSet.forEach(ch => {
|
||||
const el = document.getElementById(`filter-channel-${ch}`);
|
||||
state[ch] = el.checked;
|
||||
channelSet.forEach(ch=>{
|
||||
state[ch] = document.getElementById(`filter-channel-${ch}`).checked;
|
||||
});
|
||||
state["routersOnly"] = document.getElementById("filter-routers-only").checked;
|
||||
|
||||
localStorage.setItem("mapFilters", JSON.stringify(state));
|
||||
}
|
||||
|
||||
function updateNodeVisibility(){
|
||||
const showRoutersOnly = document.getElementById("filter-routers-only").checked;
|
||||
const activeChannels = Array.from(channelSet).filter(ch=>document.getElementById(`filter-channel-${ch}`).checked);
|
||||
const routerOnly = document.getElementById("filter-routers-only").checked;
|
||||
const activeChannels = [...channelSet].filter(ch =>
|
||||
document.getElementById(`filter-channel-${ch}`).checked
|
||||
);
|
||||
|
||||
nodes.forEach(n=>{
|
||||
const marker = markerById[n.key];
|
||||
if(marker){
|
||||
const visible = (!showRoutersOnly || n.isRouter) && activeChannels.includes(n.channel);
|
||||
if(visible) map.addLayer(marker); else map.removeLayer(marker);
|
||||
const visible =
|
||||
(!routerOnly || n.isRouter) &&
|
||||
activeChannels.includes(n.channel);
|
||||
|
||||
visible ? map.addLayer(marker) : map.removeLayer(marker);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------- Share / Reset ----------------------
|
||||
function shareCurrentView() {
|
||||
const center = map.getCenter();
|
||||
const zoom = map.getZoom();
|
||||
const lat = center.lat.toFixed(6);
|
||||
const lng = center.lng.toFixed(6);
|
||||
/* ======================================================
|
||||
SHARE / RESET
|
||||
====================================================== */
|
||||
|
||||
const shareUrl = `${window.location.origin}/map?lat=${lat}&lng=${lng}&zoom=${zoom}`;
|
||||
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';
|
||||
function shareCurrentView() {
|
||||
const c = map.getCenter();
|
||||
const url = `${window.location.origin}/map?lat=${c.lat.toFixed(6)}&lng=${c.lng.toFixed(6)}&zoom=${map.getZoom()}`;
|
||||
|
||||
navigator.clipboard.writeText(url).then(()=>{
|
||||
const btn = document.getElementById('share-button');
|
||||
const old = btn.textContent;
|
||||
btn.textContent = '✓ ' + (mapTranslations.link_copied || 'Link Copied!');
|
||||
btn.style.backgroundColor = '#2196F3';
|
||||
|
||||
setTimeout(()=>{
|
||||
btn.textContent = old;
|
||||
btn.style.backgroundColor = '#4CAF50';
|
||||
}, 2000);
|
||||
}).catch(() => alert('Share this link:\n' + shareUrl));
|
||||
});
|
||||
}
|
||||
|
||||
function resetFiltersToDefaults(){
|
||||
document.getElementById("filter-routers-only").checked = false;
|
||||
channelSet.forEach(ch=>document.getElementById(`filter-channel-${ch}`).checked = true);
|
||||
channelSet.forEach(ch => {
|
||||
document.getElementById(`filter-channel-${ch}`).checked = true;
|
||||
});
|
||||
saveFiltersToLocalStorage();
|
||||
updateNodeVisibility();
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
TRANSLATION LOAD
|
||||
====================================================== */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
loadTranslationsMap();
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,75 +1,233 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
.timestamp {
|
||||
min-width:10em;
|
||||
}
|
||||
.chat-packet:nth-of-type(odd){
|
||||
background-color: #3a3a3a; /* Lighter than #2a2a2a */
|
||||
}
|
||||
.timestamp { min-width: 10em; color: #ccc; }
|
||||
|
||||
.chat-packet:nth-of-type(odd) { background-color: #3a3a3a; }
|
||||
.chat-packet {
|
||||
border-bottom: 1px solid #555;
|
||||
padding: 8px;
|
||||
border-radius: 8px; /* Adjust the value to make the corners more or less rounded */
|
||||
padding: 3px 6px;
|
||||
border-radius: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
.chat-packet:nth-of-type(even){
|
||||
background-color: #333333; /* Slightly lighter than the previous #181818 */
|
||||
|
||||
.chat-packet > [class^="col-"] {
|
||||
padding-left: 10px !important;
|
||||
padding-right: 10px !important;
|
||||
padding-top: 1px !important;
|
||||
padding-bottom: 1px !important;
|
||||
}
|
||||
|
||||
.chat-packet:nth-of-type(even) { background-color: #333333; }
|
||||
|
||||
.channel { font-style: italic; color: #bbb; }
|
||||
.channel a { font-style: normal; color: #999; }
|
||||
|
||||
#weekly-message { margin: 15px 0; font-weight: bold; color: #ffeb3b; }
|
||||
#total-count { margin-bottom: 10px; font-style: italic; color: #ccc; }
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<span>{{ site_config["site"]["weekly_net_message"] }}</span> <br><br>
|
||||
<!-- ⭐ NET TITLE WITH ICON ⭐ -->
|
||||
<div class="container px-2">
|
||||
<h2 style="color:white; margin:0 0 10px 0;">
|
||||
<span class="icon">💬</span>
|
||||
<span data-translate-lang="net_title"></span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<h5>
|
||||
<span data-translate-lang="number_of_checkins">Number of Check-ins:</span> {{ packets|length }}
|
||||
</h5>
|
||||
</div>
|
||||
<!-- Weekly network message -->
|
||||
<div id="weekly-message"></div>
|
||||
|
||||
<div class="container">
|
||||
{% for packet in packets %}
|
||||
<div
|
||||
class="row chat-packet"
|
||||
data-packet-id="{{ packet.id }}"
|
||||
role="article"
|
||||
aria-label="Chat message from {{ packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }}"
|
||||
>
|
||||
<span class="col-2 timestamp">
|
||||
{{ packet.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y') }}
|
||||
</span>
|
||||
<span class="col-1 timestamp">
|
||||
<a href="/packet/{{ packet.id }}" title="View packet details">✉️</a> {{ packet.from_node.channel }}
|
||||
</span>
|
||||
<span class="col-2 username">
|
||||
<a href="/packet_list/{{ packet.from_node_id }}" title="View all packets from this node">
|
||||
{{ packet.from_node.long_name or (packet.from_node_id | node_id_to_hex) }}
|
||||
</a>
|
||||
</span>
|
||||
<span class="col-5 message">
|
||||
{{ packet.payload }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<span data-translate-lang="no_packets_found">No packets found.</span>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Total message count -->
|
||||
<div id="total-count">
|
||||
<span data-translate-lang="total_messages">Total messages:</span>
|
||||
<span id="total-count-value">0</span>
|
||||
</div>
|
||||
|
||||
<div id="chat-container">
|
||||
<div class="container" id="chat-log"></div>
|
||||
</div>
|
||||
</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", async () => {
|
||||
|
||||
document.addEventListener("DOMContentLoaded", loadTranslations());
|
||||
const chatContainer = document.querySelector("#chat-log");
|
||||
const weeklyMessageEl = document.querySelector("#weekly-message");
|
||||
const totalCountValueEl = document.querySelector("#total-count-value");
|
||||
|
||||
if (!chatContainer || !weeklyMessageEl || !totalCountValueEl) {
|
||||
console.error("Required elements missing");
|
||||
return;
|
||||
}
|
||||
|
||||
const renderedPacketIds = new Set();
|
||||
let netTranslations = {};
|
||||
let netTag = "";
|
||||
|
||||
/* -----------------------------------
|
||||
Escape HTML safely
|
||||
----------------------------------- */
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement("div");
|
||||
div.textContent = text ?? "";
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/* -----------------------------------
|
||||
Apply translations
|
||||
----------------------------------- */
|
||||
function applyTranslations(trans, root=document) {
|
||||
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if (trans[key]) el.textContent = trans[key];
|
||||
});
|
||||
root.querySelectorAll("[data-translate-lang-title]").forEach(el => {
|
||||
const key = el.dataset.translateLangTitle;
|
||||
if (trans[key]) el.title = trans[key];
|
||||
});
|
||||
}
|
||||
|
||||
/* -----------------------------------
|
||||
Update count
|
||||
----------------------------------- */
|
||||
function updateTotalCount() {
|
||||
totalCountValueEl.textContent = renderedPacketIds.size;
|
||||
}
|
||||
|
||||
/* -----------------------------------
|
||||
Render single packet
|
||||
----------------------------------- */
|
||||
function renderPacket(packet) {
|
||||
if (renderedPacketIds.has(packet.id)) return;
|
||||
renderedPacketIds.add(packet.id);
|
||||
|
||||
const date = new Date(packet.import_time_us / 1000);
|
||||
|
||||
const timeStr = date.toLocaleTimeString([], {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: true
|
||||
});
|
||||
|
||||
const dateStr =
|
||||
`${String(date.getMonth()+1).padStart(2,"0")}/`+
|
||||
`${String(date.getDate()).padStart(2,"0")}/`+
|
||||
date.getFullYear();
|
||||
|
||||
const timestamp = `${timeStr} - ${dateStr}`;
|
||||
|
||||
const fromName =
|
||||
(packet.long_name || "").trim() ||
|
||||
`${netTranslations.node_fallback} ${packet.from_node_id}`;
|
||||
|
||||
const div = document.createElement("div");
|
||||
div.className = "row chat-packet";
|
||||
div.dataset.packetId = packet.id;
|
||||
|
||||
div.innerHTML = `
|
||||
<span class="col-2 timestamp" title="${packet.import_time_us}">
|
||||
${timestamp}
|
||||
</span>
|
||||
|
||||
<span class="col-2 channel">
|
||||
<a href="/packet/${packet.id}"
|
||||
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(fromName)}
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<span class="col-5 message">
|
||||
${escapeHtml(packet.payload).replace(/\n/g,"<br>")}
|
||||
</span>
|
||||
`;
|
||||
|
||||
chatContainer.prepend(div);
|
||||
applyTranslations(netTranslations, div);
|
||||
updateTotalCount();
|
||||
}
|
||||
|
||||
/* -----------------------------------
|
||||
Sort descending by time
|
||||
----------------------------------- */
|
||||
function renderPacketsEnsureDescending(packets) {
|
||||
if (!packets || !packets.length) return;
|
||||
const sorted = packets.slice().sort((a, b) => b.import_time_us - a.import_time_us);
|
||||
for (let i = sorted.length - 1; i >= 0; i--) {
|
||||
renderPacket(sorted[i]);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------
|
||||
Fetch initial net-tagged packets
|
||||
----------------------------------- */
|
||||
async function fetchInitialPackets(tag) {
|
||||
if (!tag) return;
|
||||
|
||||
try {
|
||||
const sixDaysAgoMs = Date.now() - 6*24*60*60*1000;
|
||||
const sinceUs = Math.floor(sixDaysAgoMs * 1000);
|
||||
|
||||
const url =
|
||||
`/api/packets?portnum=1&contains=${encodeURIComponent(tag)}&since=${sinceUs}`;
|
||||
|
||||
const resp = await fetch(url);
|
||||
const data = await resp.json();
|
||||
|
||||
if (data?.packets?.length)
|
||||
renderPacketsEnsureDescending(data.packets);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Initial fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------
|
||||
Load translations from section=net
|
||||
----------------------------------- */
|
||||
async function loadTranslations(cfg) {
|
||||
try {
|
||||
const lang = cfg?.site?.language || "en";
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=net`);
|
||||
netTranslations = await res.json();
|
||||
applyTranslations(netTranslations, document);
|
||||
} catch (err) {
|
||||
console.error("Failed loading translations", err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -----------------------------------
|
||||
MAIN
|
||||
----------------------------------- */
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const site = cfg?.site || {};
|
||||
|
||||
netTag = site.net_tag || "";
|
||||
|
||||
weeklyMessageEl.textContent = site.weekly_net_message || "";
|
||||
|
||||
|
||||
await loadTranslations(cfg);
|
||||
await fetchInitialPackets(netTag);
|
||||
|
||||
} catch (err) {
|
||||
console.error("Initialization failed:", err);
|
||||
|
||||
weeklyMessageEl.textContent =
|
||||
netTranslations.failed_to_load_site_config ||
|
||||
"Failed to load site config.";
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,58 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
#node_info {
|
||||
height:100%;
|
||||
}
|
||||
#map{
|
||||
height:100%;
|
||||
min-height: 400px;
|
||||
}
|
||||
#packet_details{
|
||||
height: 95vh;
|
||||
overflow: scroll;
|
||||
top: 3em;
|
||||
}
|
||||
div.tab-pane > dl {
|
||||
display: inline-block;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% include "search_form.html" %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col mb-3">
|
||||
<div class="card" id="node_info">
|
||||
{% if node %}
|
||||
<div class="card-header">
|
||||
{{node.long_name}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<dl >
|
||||
<dt>ShortName</dt>
|
||||
<dd>{{node.short_name}}</dd>
|
||||
<dt>HW Model</dt>
|
||||
<dd>{{node.hw_model}}</dd>
|
||||
<dt>Role</dt>
|
||||
<dd>{{node.role}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-body">
|
||||
A NodeInfo has not been seen.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% include 'packet_list.html' %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="col mb-3">
|
||||
<div id="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,263 +0,0 @@
|
||||
{% macro graph(name) %}
|
||||
<div id="{{name}}Chart" style="width: 100%; height: 100%;"></div>
|
||||
{% endmacro %}
|
||||
|
||||
<!-- Download and Expand buttons -->
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<button class="btn btn-sm btn-outline-light me-2" id="downloadCsvBtn">Download CSV</button>
|
||||
<button class="btn btn-sm btn-outline-light" data-bs-toggle="modal" data-bs-target="#fullChartModal">Expand</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
{% for name in [
|
||||
"power", "utilization", "temperature", "humidity", "pressure",
|
||||
"iaq", "wind_speed", "wind_direction", "power_metrics", "neighbors"
|
||||
] %}
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link {% if loop.first %}active{% endif %}" data-bs-toggle="tab" data-bs-target="#{{name}}Tab" type="button" role="tab">{{ name | capitalize }}</button>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div class="tab-content mt-3" style="height: 40vh;">
|
||||
{% for name in [
|
||||
"power", "utilization", "temperature", "humidity", "pressure",
|
||||
"iaq", "wind_speed", "wind_direction", "power_metrics", "neighbors"
|
||||
] %}
|
||||
<div class="tab-pane fade {% if loop.first %}show active{% endif %}" id="{{name}}Tab" role="tabpanel" style="height: 100%;">
|
||||
{{ graph(name) | safe }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Fullscreen Modal -->
|
||||
<div class="modal fade" id="fullChartModal" tabindex="-1" aria-labelledby="fullChartModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-fullscreen">
|
||||
<div class="modal-content bg-dark text-white">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="fullChartModalLabel">Full Graph</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body" style="height: 100vh;">
|
||||
<div id="fullChartContainer" style="width: 100%; height: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ECharts Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let currentChart = null;
|
||||
let currentChartName = null;
|
||||
let currentChartData = null;
|
||||
let fullChart = null;
|
||||
|
||||
async function loadChart(name, targetDiv) {
|
||||
currentChartName = name;
|
||||
const chartDiv = document.getElementById(targetDiv);
|
||||
if (!chartDiv) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/graph/${name}_json/{{ node_id }}`);
|
||||
if (!resp.ok) throw new Error(`Failed to load data for ${name}`);
|
||||
const data = await resp.json();
|
||||
|
||||
// Reverse for chronological order
|
||||
data.timestamps.reverse();
|
||||
data.series.forEach(s => s.data.reverse());
|
||||
|
||||
const formattedDates = data.timestamps.map(t => {
|
||||
const d = new Date(t);
|
||||
return `${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}-${d.getFullYear().toString().slice(-2)}`;
|
||||
});
|
||||
|
||||
currentChartData = {
|
||||
...data,
|
||||
timestamps: formattedDates
|
||||
};
|
||||
|
||||
const chart = echarts.init(chartDiv);
|
||||
|
||||
const isDualAxis = name === 'power';
|
||||
|
||||
chart.setOption({
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function (params) {
|
||||
return params.map(p => {
|
||||
const label = p.seriesName.toLowerCase();
|
||||
const unit = label.includes('volt') ? 'V' : label.includes('battery') ? '%' : '';
|
||||
return `${p.marker} ${p.seriesName}: ${p.data}${unit}`;
|
||||
}).join('<br>');
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: formattedDates,
|
||||
axisLabel: { color: '#fff', rotate: 45 },
|
||||
},
|
||||
yAxis: isDualAxis ? [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Battery (%)',
|
||||
min: 0,
|
||||
max: 120,
|
||||
position: 'left',
|
||||
axisLabel: { color: '#fff' },
|
||||
nameTextStyle: { color: '#fff' }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Voltage (V)',
|
||||
min: 0,
|
||||
max: 6,
|
||||
position: 'right',
|
||||
axisLabel: { color: '#fff' },
|
||||
nameTextStyle: { color: '#fff' }
|
||||
}
|
||||
] : {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#fff' },
|
||||
},
|
||||
series: data.series.map(s => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
data: s.data,
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
showSymbol: false,
|
||||
yAxisIndex: isDualAxis && s.name.toLowerCase().includes('volt') ? 1 : 0,
|
||||
})),
|
||||
legend: { textStyle: { color: '#fff' } }
|
||||
});
|
||||
|
||||
return chart;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
currentChartData = null;
|
||||
currentChartName = null;
|
||||
chartDiv.innerHTML = `<div class="text-white text-center mt-5">Error loading ${name} data.</div>`;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Load first chart
|
||||
const firstTabBtn = document.querySelector('.nav-tabs button.nav-link.active');
|
||||
if (firstTabBtn) {
|
||||
const name = firstTabBtn.textContent.toLowerCase();
|
||||
const chartId = `${name}Chart`;
|
||||
loadChart(name, chartId).then(chart => currentChart = chart);
|
||||
}
|
||||
|
||||
// On tab switch
|
||||
document.querySelectorAll('.nav-tabs button.nav-link').forEach(button => {
|
||||
button.addEventListener('shown.bs.tab', event => {
|
||||
const name = event.target.textContent.toLowerCase();
|
||||
const chartId = `${name}Chart`;
|
||||
loadChart(name, chartId).then(chart => currentChart = chart);
|
||||
});
|
||||
});
|
||||
|
||||
// CSV Download
|
||||
document.getElementById('downloadCsvBtn').addEventListener('click', () => {
|
||||
if (!currentChartData || !currentChartName) {
|
||||
alert("Chart data not loaded yet.");
|
||||
return;
|
||||
}
|
||||
|
||||
const { timestamps, series } = currentChartData;
|
||||
let csv = 'Date,' + series.map(s => s.name).join(',') + '\n';
|
||||
|
||||
for (let i = 0; i < timestamps.length; i++) {
|
||||
const row = [timestamps[i]];
|
||||
for (const s of series) {
|
||||
row.push(s.data[i] != null ? s.data[i] : '');
|
||||
}
|
||||
csv += row.join(',') + '\n';
|
||||
}
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${currentChartName}_{{ node_id }}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
|
||||
// Fullscreen modal chart
|
||||
document.getElementById('fullChartModal').addEventListener('shown.bs.modal', () => {
|
||||
if (!currentChartData || !currentChartName) return;
|
||||
|
||||
if (!fullChart) {
|
||||
fullChart = echarts.init(document.getElementById('fullChartContainer'));
|
||||
}
|
||||
|
||||
const isDualAxis = currentChartName === 'power';
|
||||
|
||||
fullChart.setOption({
|
||||
title: { text: currentChartName.charAt(0).toUpperCase() + currentChartName.slice(1), textStyle: { color: '#fff' } },
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
formatter: function (params) {
|
||||
return params.map(p => {
|
||||
const label = p.seriesName.toLowerCase();
|
||||
const unit = label.includes('volt') ? 'V' : label.includes('battery') ? '%' : '';
|
||||
return `${p.marker} ${p.seriesName}: ${p.data}${unit}`;
|
||||
}).join('<br>');
|
||||
}
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: currentChartData.timestamps,
|
||||
axisLabel: { color: '#fff', rotate: 45 },
|
||||
},
|
||||
yAxis: isDualAxis ? [
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Battery (%)',
|
||||
min: 0,
|
||||
max: 120,
|
||||
position: 'left',
|
||||
axisLabel: { color: '#fff' },
|
||||
nameTextStyle: { color: '#fff' }
|
||||
},
|
||||
{
|
||||
type: 'value',
|
||||
name: 'Voltage (V)',
|
||||
min: 0,
|
||||
max: 6,
|
||||
position: 'right',
|
||||
axisLabel: { color: '#fff' },
|
||||
nameTextStyle: { color: '#fff' }
|
||||
}
|
||||
] : {
|
||||
type: 'value',
|
||||
axisLabel: { color: '#fff' },
|
||||
},
|
||||
series: currentChartData.series.map(s => ({
|
||||
name: s.name,
|
||||
type: 'line',
|
||||
data: s.data,
|
||||
smooth: true,
|
||||
connectNulls: true,
|
||||
showSymbol: false,
|
||||
yAxisIndex: isDualAxis && s.name.toLowerCase().includes('volt') ? 1 : 0,
|
||||
})),
|
||||
legend: { textStyle: { color: '#fff' } }
|
||||
});
|
||||
|
||||
fullChart.resize();
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (fullChart) fullChart.resize();
|
||||
if (currentChart) currentChart.resize();
|
||||
});
|
||||
});
|
||||
</script>
|
||||
@@ -1,109 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
.table-title {
|
||||
font-size: 2rem;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.traffic-table {
|
||||
width: 50%;
|
||||
border-collapse: collapse;
|
||||
margin: 0 auto;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.traffic-table th,
|
||||
.traffic-table td {
|
||||
padding: 10px 15px;
|
||||
text-align: left;
|
||||
border: 1px solid #474b4e;
|
||||
}
|
||||
|
||||
.traffic-table th {
|
||||
background-color: #272b2f;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.traffic:nth-of-type(odd) {
|
||||
background-color: #272b2f; /* Lighter than #2a2a2a */
|
||||
}
|
||||
|
||||
.traffic {
|
||||
border: 1px solid #474b4e;
|
||||
padding: 8px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.traffic:nth-of-type(even) {
|
||||
background-color: #212529; /* Slightly lighter than the previous #181818 */
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<section>
|
||||
<h2 class="table-title">
|
||||
{% if traffic %}
|
||||
{{ traffic[0].long_name }} (last 24 hours)
|
||||
{% else %}
|
||||
No Traffic Data Available
|
||||
{% endif %}
|
||||
</h2>
|
||||
<table class="traffic-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Port Number</th>
|
||||
<th>Packet Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for port in traffic %}
|
||||
<tr class="traffic">
|
||||
<td>
|
||||
{% if port.portnum == 1 %}
|
||||
TEXT_MESSAGE_APP
|
||||
{% elif port.portnum == 3 %}
|
||||
POSITION_APP
|
||||
{% elif port.portnum == 4 %}
|
||||
NODEINFO_APP
|
||||
{% elif port.portnum == 5 %}
|
||||
ROUTING_APP
|
||||
{% elif port.portnum == 8 %}
|
||||
WAYPOINT_APP
|
||||
{% elif port.portnum == 67 %}
|
||||
TELEMETRY_APP
|
||||
{% elif port.portnum == 70 %}
|
||||
TRACEROUTE_APP
|
||||
{% elif port.portnum == 71 %}
|
||||
NEIGHBORINFO_APP
|
||||
{% elif port.portnum == 73 %}
|
||||
MAP_REPORT_APP
|
||||
{% elif port.portnum == 0 %}
|
||||
UNKNOWN_APP
|
||||
{% else %}
|
||||
{{ port.portnum }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ port.packet_count }}</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="2">No traffic data available for this node.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<footer class="footer">
|
||||
<a href="/top">Back to Top Nodes</a>
|
||||
</footer>
|
||||
{% endblock %}
|
||||
@@ -13,11 +13,13 @@
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
/* Search UI */
|
||||
.search-container {
|
||||
position: absolute;
|
||||
bottom: 100px;
|
||||
left: 10px;
|
||||
z-index: 10;1
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
@@ -37,6 +39,8 @@
|
||||
.search-container button:hover {
|
||||
background-color: #0056b3;
|
||||
}
|
||||
|
||||
/* Node info box */
|
||||
#node-info {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
@@ -52,6 +56,8 @@
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Legend */
|
||||
#legend {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
@@ -67,9 +73,6 @@
|
||||
}
|
||||
.legend-category {
|
||||
margin-right: 10px;
|
||||
code {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
.legend-box {
|
||||
display: inline-block;
|
||||
@@ -77,22 +80,23 @@
|
||||
height: 12px;
|
||||
margin-right: 5px;
|
||||
border-radius: 3px;
|
||||
&.circle {
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
.circle { border-radius: 6px; }
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="mynetwork"></div>
|
||||
|
||||
<!-- SEARCH + FILTER -->
|
||||
<div class="search-container">
|
||||
<label for="channel-select" style="color:#333;">Channel:</label>
|
||||
<label style="color:#333;">Channel:</label>
|
||||
<select id="channel-select" onchange="filterByChannel()"></select>
|
||||
|
||||
<input type="text" id="node-search" placeholder="Search node...">
|
||||
<button onclick="searchNode()">Search</button>
|
||||
</div>
|
||||
|
||||
<!-- INFO BOX -->
|
||||
<div id="node-info">
|
||||
<b>Long Name:</b> <span id="node-long-name"></span><br>
|
||||
<b>Short Name:</b> <span id="node-short-name"></span><br>
|
||||
@@ -100,196 +104,280 @@
|
||||
<b>Hardware Model:</b> <span id="node-hw-model"></span>
|
||||
</div>
|
||||
|
||||
<!-- LEGEND -->
|
||||
<div id="legend">
|
||||
<div class="legend-category">
|
||||
<div><span class="legend-box" style="background-color: #ff5733"></span> Traceroute</div>
|
||||
<div><span class="legend-box" style="background-color: #049acd"></span> Neighbor</div>
|
||||
<div><span class="legend-box" style="background-color: #ff5733"></span>Traceroute</div>
|
||||
<div><span class="legend-box" style="background-color: #049acd"></span>Neighbor</div>
|
||||
</div>
|
||||
<div class="legend-category">
|
||||
<div><span class="legend-box circle" style="background-color: #ff5733"></span> <code>ROUTER</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #b65224"></span> <code>ROUTER_LATE</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #ff5733"></span><code>ROUTER</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #b65224"></span><code>ROUTER_LATE</code></div>
|
||||
</div>
|
||||
<div class="legend-category">
|
||||
<div><span class="legend-box circle" style="background-color: #007bff"></span> <code>CLIENT</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #00c3ff"></span> <code>CLIENT_MUTE</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #007bff"></span><code>CLIENT</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #00c3ff"></span><code>CLIENT_MUTE</code></div>
|
||||
</div>
|
||||
<div class="legend-category">
|
||||
<div><span class="legend-box circle" style="background-color: #049acd"></span> <code>CLIENT_BASE</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #ffbf00"></span> Other</div>
|
||||
<div><span class="legend-box circle" style="background-color: #049acd"></span><code>CLIENT_BASE</code></div>
|
||||
<div><span class="legend-box circle" style="background-color: #ffbf00"></span>Other</div>
|
||||
</div>
|
||||
<div class="legend-category">
|
||||
<div><span class="legend-box circle" style="background-color: #6c757d"></span> Unknown</div>
|
||||
<div><span class="legend-box circle" style="background-color: #6c757d"></span>Unknown</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const chart = echarts.init(document.getElementById('mynetwork'));
|
||||
// Initialize ECharts
|
||||
const chart = echarts.init(document.getElementById("mynetwork"));
|
||||
|
||||
// -----------------------------------
|
||||
// COLOR + ROLE HELPERS
|
||||
// -----------------------------------
|
||||
const colors = {
|
||||
edge: {
|
||||
traceroute: '#ff5733',
|
||||
neighbor: '#049acd',
|
||||
},
|
||||
edge: { traceroute:"#ff5733", neighbor:"#049acd" },
|
||||
role: {
|
||||
ROUTER: '#ff5733',
|
||||
ROUTER_LATE: '#b65224',
|
||||
CLIENT: '#007bff',
|
||||
CLIENT_MUTE: '#00c3ff',
|
||||
CLIENT_BASE: '#049acd',
|
||||
other: '#ffbf00',
|
||||
unknown: '#6c757d',
|
||||
ROUTER:"#ff5733",
|
||||
ROUTER_LATE:"#b65224",
|
||||
CLIENT:"#007bff",
|
||||
CLIENT_MUTE:"#00c3ff",
|
||||
CLIENT_BASE:"#049acd",
|
||||
other:"#ffbf00",
|
||||
unknown:"#6c757d"
|
||||
},
|
||||
selection: '#ff8c00',
|
||||
selection:"#ff8c00"
|
||||
};
|
||||
|
||||
function getRoleColor(role) {
|
||||
if (!role) return colors.role.unknown;
|
||||
return colors.role[role] || colors.role.other;
|
||||
}
|
||||
|
||||
|
||||
function getSymbolSize (role) {
|
||||
switch (role) {
|
||||
case 'ROUTER': return 30;
|
||||
case 'ROUTER_LATE': return 30;
|
||||
case 'CLIENT_BASE': return 18;
|
||||
case 'CLIENT': return 15;
|
||||
case 'CLIENT_MUTE': return 7;
|
||||
default: return 15; // Unknown or other roles
|
||||
function getRoleColor(role) { return colors.role[role] || colors.role.other; }
|
||||
function getSymbolSize(role) {
|
||||
switch(role){
|
||||
case "ROUTER":
|
||||
case "ROUTER_LATE": return 30;
|
||||
case "CLIENT_BASE": return 18;
|
||||
case "CLIENT": return 15;
|
||||
case "CLIENT_MUTE": return 7;
|
||||
default: return 15;
|
||||
}
|
||||
}
|
||||
|
||||
function getLabel (role, short_name, long_name) {
|
||||
if (role === 'ROUTER') return long_name;
|
||||
if (role === 'ROUTER_LATE') return long_name;
|
||||
if (role === 'CLIENT_BASE') return short_name;
|
||||
if (role === 'CLIENT') return short_name;
|
||||
if (role === 'CLIENT_MUTE') return short_name;
|
||||
return short_name || '';
|
||||
function getLabel(role, shortName, longName) {
|
||||
if (role === "ROUTER" || role === "ROUTER_LATE") return longName;
|
||||
return shortName || "";
|
||||
}
|
||||
|
||||
// --- Nodes ---
|
||||
const nodes = [
|
||||
{% for node in nodes %}
|
||||
{
|
||||
name: "{{ node.node_id }}", // node_id as string
|
||||
value: getLabel({{node.role | tojson}}, {{node.short_name | tojson }}, {{node.long_name | tojson}}), // display label
|
||||
symbol: 'circle',
|
||||
symbolSize: getSymbolSize({{node.role | tojson}}),
|
||||
itemStyle: { color: getRoleColor({{node.role | tojson}}), opacity:1 },
|
||||
label: { show:true, position:'right', color:'#333', fontSize:12, formatter: (p)=>p.data.value },
|
||||
long_name: {{ node.long_name | tojson }},
|
||||
short_name: {{ node.short_name | tojson }},
|
||||
role: {{ node.role | tojson }},
|
||||
hw_model: {{ node.hw_model | tojson }},
|
||||
channel: {{ node.channel | tojson }}
|
||||
},
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
// --- Edges ---
|
||||
const edges = [
|
||||
{% for edge in edges %}
|
||||
{
|
||||
source: "{{ edge.from }}", // edge source as string
|
||||
target: "{{ edge.to }}", // edge target as string
|
||||
originalColor: colors.edge[{{edge.type | tojson}}],
|
||||
lineStyle: {
|
||||
color: colors.edge[{{edge.type | tojson}}],
|
||||
width: {{edge.weight | tojson}},
|
||||
},
|
||||
},
|
||||
{% endfor %}
|
||||
];
|
||||
|
||||
// -----------------------------------
|
||||
// STATE
|
||||
// -----------------------------------
|
||||
let nodes = [];
|
||||
let edges = [];
|
||||
let filteredNodes = [];
|
||||
let filteredEdges = [];
|
||||
let selectedChannel = 'LongFast';
|
||||
let lastSelectedNode = null;
|
||||
let selectedChannel = null;
|
||||
|
||||
// -----------------------------------
|
||||
// LOAD NODES + EDGES FROM API
|
||||
// -----------------------------------
|
||||
async function loadData() {
|
||||
|
||||
// 1. Load nodes
|
||||
const n = await fetch("/api/nodes").then(r => r.json());
|
||||
nodes = n.nodes.map(x => ({
|
||||
name: String(x.node_id),
|
||||
node_id: x.node_id,
|
||||
long_name: x.long_name,
|
||||
short_name: x.short_name,
|
||||
hw_model: x.hw_model,
|
||||
role: x.role,
|
||||
channel: x.channel,
|
||||
labelValue: getLabel(x.role, x.short_name, x.long_name),
|
||||
symbolSize: getSymbolSize(x.role),
|
||||
itemStyle: {
|
||||
color: getRoleColor(x.role),
|
||||
opacity: 1
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: "right",
|
||||
color: "#333",
|
||||
fontSize: 12,
|
||||
formatter: p => p.data.labelValue
|
||||
}
|
||||
}));
|
||||
|
||||
const allNodeIDs = new Set(nodes.map(n => n.name));
|
||||
|
||||
// 2. Load edges
|
||||
const e = await fetch("/api/edges").then(r => r.json());
|
||||
|
||||
// Only keep edges that reference valid nodes
|
||||
edges = e.edges
|
||||
.filter(ed =>
|
||||
allNodeIDs.has(String(ed.from)) &&
|
||||
allNodeIDs.has(String(ed.to))
|
||||
)
|
||||
.map(ed => ({
|
||||
source: String(ed.from),
|
||||
target: String(ed.to),
|
||||
edgeType: ed.type,
|
||||
originalColor: colors.edge[ed.type] || "#ccc",
|
||||
lineStyle: {
|
||||
width: 2,
|
||||
color: colors.edge[ed.type] || "#ccc",
|
||||
opacity: 1
|
||||
}
|
||||
}));
|
||||
|
||||
// 3. Determine which nodes are actually used in edges
|
||||
const usedNodeIDs = new Set();
|
||||
edges.forEach(e => {
|
||||
usedNodeIDs.add(e.source);
|
||||
usedNodeIDs.add(e.target);
|
||||
});
|
||||
|
||||
// 4. Remove unused (no-edge) nodes
|
||||
nodes = nodes.filter(n => usedNodeIDs.has(n.name));
|
||||
|
||||
// 5. Double safety: remove any edges referencing removed nodes
|
||||
edges = edges.filter(e =>
|
||||
usedNodeIDs.has(e.source) && usedNodeIDs.has(e.target)
|
||||
);
|
||||
|
||||
// 6. Now ready to build dropdown & render
|
||||
populateChannelDropdown();
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------------
|
||||
// CHANNEL FILTER
|
||||
// -----------------------------------
|
||||
function populateChannelDropdown() {
|
||||
const sel = document.getElementById('channel-select');
|
||||
const unique = [...new Set(nodes.map(n=>n.channel).filter(Boolean))].sort();
|
||||
unique.forEach(ch=>{
|
||||
const opt = document.createElement('option');
|
||||
opt.value=ch; opt.text=ch;
|
||||
if(ch==='LongFast') opt.selected=true;
|
||||
const sel = document.getElementById("channel-select");
|
||||
const chans = [...new Set(nodes.map(n => n.channel))].sort();
|
||||
|
||||
chans.forEach(ch => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ch;
|
||||
opt.text = ch;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
selectedChannel = sel.value;
|
||||
|
||||
selectedChannel = chans[0];
|
||||
sel.value = selectedChannel;
|
||||
|
||||
filterByChannel();
|
||||
}
|
||||
|
||||
function filterByChannel() {
|
||||
selectedChannel = document.getElementById('channel-select').value;
|
||||
filteredNodes = nodes.filter(n=>n.channel===selectedChannel);
|
||||
const nodeSet = new Set(filteredNodes.map(n=>n.name));
|
||||
filteredEdges = edges.filter(e=>nodeSet.has(e.source) && nodeSet.has(e.target));
|
||||
lastSelectedNode=null;
|
||||
selectedChannel = document.getElementById("channel-select").value;
|
||||
|
||||
filteredNodes = nodes.filter(n => n.channel === selectedChannel);
|
||||
|
||||
const allowed = new Set(filteredNodes.map(n => n.name));
|
||||
filteredEdges = edges.filter(e => allowed.has(e.source) && allowed.has(e.target));
|
||||
|
||||
lastSelectedNode = null;
|
||||
updateChart();
|
||||
}
|
||||
|
||||
// -----------------------------------
|
||||
// FORCE GRAPH UPDATE
|
||||
// -----------------------------------
|
||||
function updateChart() {
|
||||
const updatedNodes = filteredNodes.map(node=>{
|
||||
let opacity=1, color=getRoleColor(node.role), borderColor='transparent', borderWidth=6;
|
||||
if(lastSelectedNode){
|
||||
const connected = filteredEdges.some(e=>
|
||||
(e.source===node.name && e.target===lastSelectedNode) ||
|
||||
(e.target===node.name && e.source===lastSelectedNode)
|
||||
const updatedNodes = filteredNodes.map(n => {
|
||||
let opacity = 1;
|
||||
let borderColor = "transparent";
|
||||
|
||||
if (lastSelectedNode) {
|
||||
const connected = filteredEdges.some(
|
||||
e => (e.source === n.name && e.target === lastSelectedNode) ||
|
||||
(e.target === n.name && e.source === lastSelectedNode)
|
||||
);
|
||||
if(node.name === lastSelectedNode) {
|
||||
opacity=1;
|
||||
borderColor=colors.selection;
|
||||
if (n.name === lastSelectedNode) {
|
||||
borderColor = colors.selection;
|
||||
} else if (!connected) {
|
||||
opacity = 0.3;
|
||||
}
|
||||
else if(connected) opacity=1;
|
||||
else opacity=0.4;
|
||||
}
|
||||
return {...node, itemStyle:{...node.itemStyle, color,opacity, borderColor, borderWidth}};
|
||||
|
||||
return {
|
||||
...n,
|
||||
itemStyle: { ...n.itemStyle, opacity, borderColor, borderWidth: 6 }
|
||||
};
|
||||
});
|
||||
|
||||
const updatedEdges = filteredEdges.map(edge=>{
|
||||
let opacity=0.1, width=edge.lineStyle.width;
|
||||
if(lastSelectedNode){
|
||||
const connected = edge.source===lastSelectedNode || edge.target===lastSelectedNode;
|
||||
opacity=connected?1:0.05; width=edge.lineStyle.width;
|
||||
}
|
||||
return {...edge, lineStyle:{color:edge.originalColor||'#d3d3d3', width, opacity}};
|
||||
const updatedEdges = filteredEdges.map(e => {
|
||||
const connected =
|
||||
lastSelectedNode &&
|
||||
(e.source === lastSelectedNode || e.target === lastSelectedNode);
|
||||
|
||||
return {
|
||||
...e,
|
||||
lineStyle: { ...e.lineStyle, opacity: connected ? 1 : 0.1 }
|
||||
};
|
||||
});
|
||||
|
||||
chart.setOption({series:[{type:'graph', layout:'force', data:updatedNodes, links:updatedEdges, roam:true, force:{repulsion:200, edgeLength:[80,120]}}]});
|
||||
chart.setOption({
|
||||
animation: false,
|
||||
series: [{
|
||||
type: "graph",
|
||||
layout: "force",
|
||||
roam: true,
|
||||
data: updatedNodes,
|
||||
links: updatedEdges,
|
||||
force: { repulsion: 200, edgeLength: [80, 120] }
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
chart.on('click', function(params){
|
||||
if(params.dataType==='node') updateSelectedNode(params.data.name);
|
||||
else{
|
||||
lastSelectedNode=null; updateChart();
|
||||
document.getElementById('node-long-name').innerText='';
|
||||
document.getElementById('node-short-name').innerText='';
|
||||
document.getElementById('node-role').innerText='';
|
||||
document.getElementById('node-hw-model').innerText='';
|
||||
// -----------------------------------
|
||||
// CLICK EVENTS
|
||||
// -----------------------------------
|
||||
chart.on("click", function(params){
|
||||
if (params.dataType === "node") {
|
||||
updateSelectedNode(params.data.name);
|
||||
} else {
|
||||
lastSelectedNode = null;
|
||||
updateChart();
|
||||
document.getElementById("node-long-name").innerText = "";
|
||||
document.getElementById("node-short-name").innerText = "";
|
||||
document.getElementById("node-role").innerText = "";
|
||||
document.getElementById("node-hw-model").innerText = "";
|
||||
}
|
||||
});
|
||||
|
||||
function updateSelectedNode(selNode){
|
||||
lastSelectedNode=selNode; updateChart();
|
||||
const n = filteredNodes.find(x=>x.name===selNode);
|
||||
if(n){
|
||||
document.getElementById('node-long-name').innerText=n.long_name;
|
||||
document.getElementById('node-short-name').innerText=n.short_name;
|
||||
document.getElementById('node-role').innerText=n.role;
|
||||
document.getElementById('node-hw-model').innerText=n.hw_model;
|
||||
}
|
||||
function updateSelectedNode(id) {
|
||||
lastSelectedNode = id;
|
||||
updateChart();
|
||||
|
||||
const n = filteredNodes.find(n => n.name === id);
|
||||
if (!n) return;
|
||||
|
||||
document.getElementById("node-long-name").innerText = n.long_name;
|
||||
document.getElementById("node-short-name").innerText = n.short_name;
|
||||
document.getElementById("node-role").innerText = n.role;
|
||||
document.getElementById("node-hw-model").innerText = n.hw_model;
|
||||
}
|
||||
|
||||
function searchNode(){
|
||||
const q = document.getElementById('node-search').value.toLowerCase().trim();
|
||||
if(!q) return;
|
||||
const found = filteredNodes.find(n=>n.name.toLowerCase().includes(q) || n.long_name.toLowerCase().includes(q) || n.short_name.toLowerCase().includes(q));
|
||||
if(found) updateSelectedNode(found.name);
|
||||
// -----------------------------------
|
||||
// SEARCH
|
||||
// -----------------------------------
|
||||
function searchNode() {
|
||||
const q = document.getElementById("node-search").value.toLowerCase().trim();
|
||||
if (!q) return;
|
||||
|
||||
const found = filteredNodes.find(n =>
|
||||
n.name.toLowerCase().includes(q) ||
|
||||
(n.long_name || "").toLowerCase().includes(q) ||
|
||||
(n.short_name || "").toLowerCase().includes(q)
|
||||
);
|
||||
|
||||
if (found) updateSelectedNode(found.name);
|
||||
else alert("Node not found in current channel!");
|
||||
}
|
||||
|
||||
populateChannelDropdown();
|
||||
window.addEventListener('resize', ()=>chart.resize());
|
||||
// -----------------------------------
|
||||
loadData();
|
||||
window.addEventListener("resize", () => chart.resize());
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,12 +1,31 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
html, body {
|
||||
overflow-x: auto !important;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 80%;
|
||||
/* FIX: allow table to keep natural width so scrolling works */
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1em auto;
|
||||
}
|
||||
|
||||
/* Desktop scroll wrapper */
|
||||
#node-list {
|
||||
width: 100%;
|
||||
overflow-x: auto; /* allows horizontal scroll */
|
||||
overflow-y: hidden;
|
||||
/* !!! removed display:flex because it prevents scrolling */
|
||||
}
|
||||
|
||||
#node-list table {
|
||||
width: max-content; /* table keeps its natural width */
|
||||
min-width: 100%; /* won't shrink smaller than viewport */
|
||||
}
|
||||
th, td {
|
||||
padding: 10px;
|
||||
border: 1px solid #333;
|
||||
@@ -84,7 +103,23 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
}
|
||||
.node-status {
|
||||
margin-left: 10px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid #2a6a8a;
|
||||
background: #0d2a3a;
|
||||
color: #9fd4ff;
|
||||
font-size: 0.9em;
|
||||
display: inline-block;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease-in-out;
|
||||
}
|
||||
.node-status.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Favorite stars */
|
||||
.favorite-star {
|
||||
cursor: pointer;
|
||||
font-size: 1.2em;
|
||||
@@ -100,6 +135,7 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
color: #ffd700;
|
||||
}
|
||||
|
||||
/* Favorite filter button */
|
||||
.favorites-btn {
|
||||
background-color: #ffd700;
|
||||
color: #000;
|
||||
@@ -114,257 +150,519 @@ select, .export-btn, .search-box, .clear-btn {
|
||||
background-color: #ff6b6b;
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* --------------------------------------------- */
|
||||
/* MOBILE CARD VIEW */
|
||||
/* --------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
|
||||
/* Hide desktop view */
|
||||
#node-list table {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Show mobile cards */
|
||||
#mobile-node-list {
|
||||
display: block !important;
|
||||
width: 100%;
|
||||
padding: 0 10px;
|
||||
|
||||
/* If you want horizontal swiping, uncomment:
|
||||
overflow-x: auto;
|
||||
white-space: nowrap; */
|
||||
}
|
||||
|
||||
.node-card {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 12px 15px;
|
||||
margin-bottom: 12px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.node-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 1.2em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.node-card-field {
|
||||
margin: 4px 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.node-card-field b {
|
||||
color: #9fd4ff;
|
||||
}
|
||||
|
||||
.favorite-star {
|
||||
font-size: 1.4em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="filter-container">
|
||||
<input type="text" id="search-box" class="search-box" placeholder="Search by name or ID..." />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
id="search-box"
|
||||
class="search-box"
|
||||
data-translate-lang="search_placeholder"
|
||||
placeholder="Search by name or ID or HEX ID..."
|
||||
/>
|
||||
|
||||
<select id="role-filter">
|
||||
<option value="">All Roles</option>
|
||||
<option value="" data-translate-lang="all_roles">All Roles</option>
|
||||
</select>
|
||||
|
||||
<select id="channel-filter">
|
||||
<option value="">All Channels</option>
|
||||
<option value="" data-translate-lang="all_channels">All Channels</option>
|
||||
</select>
|
||||
|
||||
<select id="hw-filter">
|
||||
<option value="">All HW Models</option>
|
||||
<option value="" data-translate-lang="all_hw">All HW Models</option>
|
||||
</select>
|
||||
|
||||
<select id="firmware-filter">
|
||||
<option value="">All Firmware</option>
|
||||
<option value="" data-translate-lang="all_firmware">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>
|
||||
<button class="favorites-btn" id="favorites-btn" data-translate-lang="show_favorites">
|
||||
⭐ Show Favorites
|
||||
</button>
|
||||
|
||||
<button class="export-btn" id="export-btn" data-translate-lang="export_csv">
|
||||
Export CSV
|
||||
</button>
|
||||
|
||||
<button class="clear-btn" id="clear-btn" data-translate-lang="clear_filters">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="count-container">
|
||||
Showing <span id="node-count">0</span> nodes
|
||||
<span data-translate-lang="showing_nodes">Showing</span>
|
||||
<span id="node-count">0</span>
|
||||
<span data-translate-lang="nodes_suffix">nodes</span>
|
||||
<span id="node-status" class="node-status" aria-live="polite"></span>
|
||||
</div>
|
||||
|
||||
<!-- Desktop table -->
|
||||
<div id="node-list">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short<span class="sort-icon">▲</span></th>
|
||||
<th>Long Name <span class="sort-icon"></span></th>
|
||||
<th>HW Model <span class="sort-icon"></span></th>
|
||||
<th>Firmware <span class="sort-icon"></span></th>
|
||||
<th>Role <span class="sort-icon"></span></th>
|
||||
<th>Last Latitude <span class="sort-icon"></span></th>
|
||||
<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>
|
||||
<th data-translate-lang="short_name">Short <span class="sort-icon">▲</span></th>
|
||||
<th data-translate-lang="long_name">Long Name <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="hw_model">HW Model <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="firmware">Firmware <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="role">Role <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="last_lat">Last Latitude <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="last_long">Last Longitude <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="channel">Channel <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="last_seen">Last Seen <span class="sort-icon"></span></th>
|
||||
<th data-translate-lang="favorite"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="node-table-body">
|
||||
<tr><td colspan="9" style="text-align:center; color:white;">Loading nodes...</td></tr>
|
||||
<tr>
|
||||
<td colspan="10" style="text-align:center; color:white;" data-translate-lang="loading_nodes">
|
||||
Loading nodes...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div id="mobile-node-list" style="display:none;"></div>
|
||||
|
||||
<script>
|
||||
let allNodes = [];
|
||||
let sortColumn = "short_name"; // default sorted column
|
||||
let sortAsc = true; // default ascending
|
||||
let showOnlyFavorites = false;
|
||||
// =====================================================
|
||||
// TRANSLATIONS
|
||||
// =====================================================
|
||||
let nodelistTranslations = {};
|
||||
|
||||
// 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 applyTranslationsNodelist() {
|
||||
document.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if (nodelistTranslations[key]) {
|
||||
if (el.tagName === "INPUT" && el.placeholder) {
|
||||
el.placeholder = nodelistTranslations[key];
|
||||
} else {
|
||||
el.textContent = nodelistTranslations[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
async function loadTranslationsNodelist() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const lang = cfg?.site?.language || "en";
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=nodelist`);
|
||||
nodelistTranslations = await res.json();
|
||||
applyTranslationsNodelist();
|
||||
} catch (err) {
|
||||
console.error("Failed to load nodelist translations:", err);
|
||||
}
|
||||
saveFavorites(favorites);
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// GLOBALS
|
||||
// =====================================================
|
||||
let allNodes = [];
|
||||
let sortColumn = "short_name";
|
||||
let sortAsc = true;
|
||||
let showOnlyFavorites = false;
|
||||
let favoritesSet = new Set();
|
||||
let isBusy = false;
|
||||
let statusHideTimer = null;
|
||||
let statusShownAt = 0;
|
||||
const minStatusMs = 300;
|
||||
|
||||
const headers = document.querySelectorAll("thead th");
|
||||
const keyMap = [
|
||||
"short_name","long_name","hw_model","firmware","role",
|
||||
"last_lat","last_long","channel","last_seen_us"
|
||||
];
|
||||
|
||||
function debounce(fn, delay = 250) {
|
||||
let t;
|
||||
return (...args) => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => fn(...args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise(resolve => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
function loadFavorites() {
|
||||
const favorites = localStorage.getItem('nodelist_favorites');
|
||||
if (!favorites) {
|
||||
favoritesSet = new Set();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(favorites);
|
||||
favoritesSet = new Set(Array.isArray(parsed) ? parsed : []);
|
||||
} catch (err) {
|
||||
console.warn("Failed to parse favorites, resetting.", err);
|
||||
favoritesSet = new Set();
|
||||
}
|
||||
}
|
||||
function saveFavorites() {
|
||||
localStorage.setItem('nodelist_favorites', JSON.stringify([...favoritesSet]));
|
||||
}
|
||||
function toggleFavorite(nodeId) {
|
||||
if (favoritesSet.has(nodeId)) {
|
||||
favoritesSet.delete(nodeId);
|
||||
} else {
|
||||
favoritesSet.add(nodeId);
|
||||
}
|
||||
saveFavorites();
|
||||
}
|
||||
function isFavorite(nodeId) {
|
||||
return getFavorites().includes(nodeId);
|
||||
return favoritesSet.has(nodeId);
|
||||
}
|
||||
|
||||
function timeAgoFromMs(msTimestamp) {
|
||||
if (!msTimestamp) return "N/A";
|
||||
const diff = Date.now() - msTimestamp;
|
||||
|
||||
if (diff < 60000) return "just now";
|
||||
const mins = Math.floor(diff / 60000);
|
||||
if (mins < 60) return `${mins} min ago`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs} hr ago`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
return `${days} days ago`;
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// DOM LOADED
|
||||
// =====================================================
|
||||
document.addEventListener("DOMContentLoaded", async function() {
|
||||
|
||||
await loadTranslationsNodelist();
|
||||
loadFavorites();
|
||||
|
||||
const tbody = document.getElementById("node-table-body");
|
||||
const mobileList = document.getElementById("mobile-node-list");
|
||||
|
||||
const roleFilter = document.getElementById("role-filter");
|
||||
const channelFilter = document.getElementById("channel-filter");
|
||||
const hwFilter = document.getElementById("hw-filter");
|
||||
const firmwareFilter = document.getElementById("firmware-filter");
|
||||
const searchBox = document.getElementById("search-box");
|
||||
const countSpan = document.getElementById("node-count");
|
||||
const statusSpan = document.getElementById("node-status");
|
||||
const exportBtn = document.getElementById("export-btn");
|
||||
const clearBtn = document.getElementById("clear-btn");
|
||||
const favoritesBtn = document.getElementById("favorites-btn");
|
||||
|
||||
let lastIsMobile = (window.innerWidth <= 768);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/nodes?days_active=3");
|
||||
if (!response.ok) throw new Error("Failed to fetch nodes");
|
||||
const data = await response.json();
|
||||
allNodes = data.nodes;
|
||||
setStatus("Loading nodes…");
|
||||
await nextFrame();
|
||||
const res = await fetch("/api/nodes?days_active=3");
|
||||
if (!res.ok) throw new Error("Failed to fetch nodes");
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
allNodes = data.nodes.map(n => {
|
||||
const firmware = n.firmware || n.firmware_version || "";
|
||||
const last_seen_us = n.last_seen_us || 0;
|
||||
const last_seen_ms = last_seen_us ? (last_seen_us / 1000) : 0;
|
||||
|
||||
return {
|
||||
...n,
|
||||
firmware,
|
||||
last_seen_us,
|
||||
last_seen_ms,
|
||||
_search: [
|
||||
n.node_id,
|
||||
n.id,
|
||||
n.long_name,
|
||||
n.short_name
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
};
|
||||
});
|
||||
|
||||
populateFilters(allNodes);
|
||||
renderTable(allNodes);
|
||||
applyFilters(); // ensures initial sort + render uses same path
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
} catch (err) {
|
||||
tbody.innerHTML = `<tr><td colspan="9" style="text-align:center; color:red;">Error loading nodes: ${err.message}</td></tr>`;
|
||||
tbody.innerHTML = `<tr>
|
||||
<td colspan="10" style="text-align:center; color:red;">
|
||||
${nodelistTranslations.error_loading_nodes || "Error loading nodes"}
|
||||
</td></tr>`;
|
||||
setStatus("");
|
||||
return;
|
||||
}
|
||||
|
||||
roleFilter.addEventListener("change", applyFilters);
|
||||
channelFilter.addEventListener("change", applyFilters);
|
||||
hwFilter.addEventListener("change", applyFilters);
|
||||
firmwareFilter.addEventListener("change", applyFilters);
|
||||
searchBox.addEventListener("input", applyFilters);
|
||||
|
||||
// Debounced only for search typing
|
||||
searchBox.addEventListener("input", debounce(applyFilters, 250));
|
||||
|
||||
exportBtn.addEventListener("click", exportToCSV);
|
||||
clearBtn.addEventListener("click", clearFilters);
|
||||
favoritesBtn.addEventListener("click", toggleFavoritesFilter);
|
||||
|
||||
// Use event delegation for star clicks
|
||||
tbody.addEventListener("click", (e) => {
|
||||
|
||||
// Favorite star click handler (delegated)
|
||||
document.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 = '★';
|
||||
const nodeId = parseInt(e.target.dataset.nodeId, 10);
|
||||
const fav = isFavorite(nodeId);
|
||||
|
||||
if (fav) {
|
||||
e.target.classList.remove("active");
|
||||
e.target.textContent = "☆";
|
||||
} else {
|
||||
e.target.classList.remove('active');
|
||||
e.target.textContent = '☆';
|
||||
e.target.classList.add("active");
|
||||
e.target.textContent = "★";
|
||||
}
|
||||
|
||||
// Save to localStorage
|
||||
|
||||
toggleFavorite(nodeId);
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
|
||||
headers.forEach((th, index) => {
|
||||
th.addEventListener("click", () => {
|
||||
const key = keyMap[index];
|
||||
// ignore clicks on the "favorite" (last header) which has no sort key
|
||||
if (!key) return;
|
||||
|
||||
sortAsc = (sortColumn === key) ? !sortAsc : true;
|
||||
sortColumn = key;
|
||||
applyFilters(); // apply filters and sort
|
||||
|
||||
applyFilters();
|
||||
});
|
||||
});
|
||||
|
||||
// Re-render on breakpoint change so mobile/desktop view switches instantly
|
||||
window.addEventListener("resize", debounce(() => {
|
||||
const isMobile = (window.innerWidth <= 768);
|
||||
if (isMobile !== lastIsMobile) {
|
||||
lastIsMobile = isMobile;
|
||||
applyFilters();
|
||||
}
|
||||
}, 150));
|
||||
|
||||
function populateFilters(nodes) {
|
||||
const roles = new Set();
|
||||
const channels = new Set();
|
||||
const hws = new Set();
|
||||
const firmwares = new Set();
|
||||
const roles = new Set(), channels = new Set(), hws = new Set(), fws = new Set();
|
||||
|
||||
nodes.forEach(n => {
|
||||
if (n.role) roles.add(n.role);
|
||||
if (n.channel) channels.add(n.channel);
|
||||
if (n.hw_model) hws.add(n.hw_model);
|
||||
if (n.firmware) firmwares.add(n.firmware);
|
||||
if (n.firmware) fws.add(n.firmware);
|
||||
});
|
||||
|
||||
fillSelect(roleFilter, roles);
|
||||
fillSelect(channelFilter, channels);
|
||||
fillSelect(hwFilter, hws);
|
||||
fillSelect(firmwareFilter, firmwares);
|
||||
fillSelect(firmwareFilter, fws);
|
||||
}
|
||||
|
||||
function fillSelect(select, values) {
|
||||
[...values].sort().forEach(v => {
|
||||
const option = document.createElement("option");
|
||||
option.value = v;
|
||||
option.textContent = v;
|
||||
select.appendChild(option);
|
||||
const opt = document.createElement("option");
|
||||
opt.value = v;
|
||||
opt.textContent = v;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFavoritesFilter() {
|
||||
showOnlyFavorites = !showOnlyFavorites;
|
||||
if (showOnlyFavorites) {
|
||||
favoritesBtn.textContent = "⭐ Show All";
|
||||
favoritesBtn.classList.add("active");
|
||||
} else {
|
||||
favoritesBtn.textContent = "⭐ Show Favorites";
|
||||
favoritesBtn.classList.remove("active");
|
||||
}
|
||||
favoritesBtn.textContent = showOnlyFavorites
|
||||
? "Show All"
|
||||
: "⭐ Show Favorites";
|
||||
favoritesBtn.classList.toggle("active", showOnlyFavorites);
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
async function applyFilters() {
|
||||
setStatus("Updating…");
|
||||
await nextFrame();
|
||||
const searchTerm = searchBox.value.trim().toLowerCase();
|
||||
|
||||
let filtered = allNodes.filter(node => {
|
||||
const roleMatch = !roleFilter.value || node.role === roleFilter.value;
|
||||
const channelMatch = !channelFilter.value || node.channel === channelFilter.value;
|
||||
const hwMatch = !hwFilter.value || node.hw_model === hwFilter.value;
|
||||
const firmwareMatch = !firmwareFilter.value || node.firmware === firmwareFilter.value;
|
||||
let filtered = allNodes.filter(n => {
|
||||
const roleMatch = !roleFilter.value || n.role === roleFilter.value;
|
||||
const channelMatch = !channelFilter.value || n.channel === channelFilter.value;
|
||||
const hwMatch = !hwFilter.value || n.hw_model === hwFilter.value;
|
||||
const fwMatch = !firmwareFilter.value || n.firmware === firmwareFilter.value;
|
||||
|
||||
const searchMatch = !searchTerm ||
|
||||
(node.long_name && node.long_name.toLowerCase().includes(searchTerm)) ||
|
||||
(node.short_name && node.short_name.toLowerCase().includes(searchTerm)) ||
|
||||
(node.node_id && node.node_id.toString().includes(searchTerm)) ||
|
||||
(node.id && node.id.toString().includes(searchTerm));
|
||||
const searchMatch = !searchTerm || n._search.includes(searchTerm);
|
||||
const favMatch = !showOnlyFavorites || isFavorite(n.node_id);
|
||||
|
||||
const favoriteMatch = !showOnlyFavorites || isFavorite(node.node_id);
|
||||
|
||||
return roleMatch && channelMatch && hwMatch && firmwareMatch && searchMatch && favoriteMatch;
|
||||
return roleMatch && channelMatch && hwMatch && fwMatch && searchMatch && favMatch;
|
||||
});
|
||||
|
||||
if (sortColumn) {
|
||||
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
||||
}
|
||||
// IMPORTANT: Always sort the filtered subset to preserve expected behavior
|
||||
filtered = sortNodes(filtered, sortColumn, sortAsc);
|
||||
|
||||
renderTable(filtered);
|
||||
updateSortIcons();
|
||||
setStatus("");
|
||||
}
|
||||
|
||||
function renderTable(nodes) {
|
||||
tbody.innerHTML = "";
|
||||
if (!nodes.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="10" style="text-align:center; color:white;">No nodes found</td></tr>';
|
||||
const isMobile = window.innerWidth <= 768;
|
||||
const shouldRenderTable = !isMobile;
|
||||
|
||||
if (shouldRenderTable) {
|
||||
tbody.innerHTML = "";
|
||||
} else {
|
||||
nodes.forEach(node => {
|
||||
mobileList.innerHTML = "";
|
||||
}
|
||||
|
||||
const tableFrag = shouldRenderTable ? document.createDocumentFragment() : null;
|
||||
const mobileFrag = shouldRenderTable ? null : document.createDocumentFragment();
|
||||
|
||||
if (!nodes.length) {
|
||||
if (shouldRenderTable) {
|
||||
tbody.innerHTML = `<tr>
|
||||
<td colspan="10" style="text-align:center; color:white;">
|
||||
${nodelistTranslations.no_nodes_found || "No nodes found"}
|
||||
</td>
|
||||
</tr>`;
|
||||
} else {
|
||||
mobileList.innerHTML = `<div style="text-align:center; color:white;">
|
||||
${nodelistTranslations.no_nodes_found || "No nodes found"}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
countSpan.textContent = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
nodes.forEach(node => {
|
||||
const fav = isFavorite(node.node_id);
|
||||
const star = fav ? "★" : "☆";
|
||||
|
||||
if (shouldRenderTable) {
|
||||
// DESKTOP TABLE ROW
|
||||
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>
|
||||
<td><a href="/node/${node.node_id}">${node.long_name || "N/A"}</a></td>
|
||||
<td>${node.hw_model || "N/A"}</td>
|
||||
<td>${node.firmware || "N/A"}</td>
|
||||
<td>${node.role || "N/A"}</td>
|
||||
<td>${node.last_lat ? (node.last_lat / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.last_long ? (node.last_long / 1e7).toFixed(7) : "N/A"}</td>
|
||||
<td>${node.channel || "N/A"}</td>
|
||||
<td>${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>
|
||||
<td>${timeAgoFromMs(node.last_seen_ms)}</td>
|
||||
<td style="text-align:center;">
|
||||
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
tableFrag.appendChild(row);
|
||||
} else {
|
||||
// MOBILE CARD VIEW
|
||||
const card = document.createElement("div");
|
||||
card.className = "node-card";
|
||||
card.innerHTML = `
|
||||
<div class="node-card-header">
|
||||
<span>${node.short_name || node.long_name || node.node_id}</span>
|
||||
<span class="favorite-star ${fav ? "active" : ""}" data-node-id="${node.node_id}">
|
||||
${star}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="node-card-field"><b>ID:</b> ${node.node_id}</div>
|
||||
<div class="node-card-field"><b>Name:</b> ${node.long_name || "N/A"}</div>
|
||||
<div class="node-card-field"><b>HW:</b> ${node.hw_model || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Firmware:</b> ${node.firmware || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Role:</b> ${node.role || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Location:</b>
|
||||
${node.last_lat ? (node.last_lat / 1e7).toFixed(5) : "N/A"},
|
||||
${node.last_long ? (node.last_long / 1e7).toFixed(5) : "N/A"}
|
||||
</div>
|
||||
<div class="node-card-field"><b>Channel:</b> ${node.channel || "N/A"}</div>
|
||||
<div class="node-card-field"><b>Last Seen:</b> ${timeAgoFromMs(node.last_seen_ms)}</div>
|
||||
|
||||
<a href="/node/${node.node_id}" style="color:#9fd4ff; text-decoration:underline; margin-top:5px; display:block;">
|
||||
View Node →
|
||||
</a>
|
||||
`;
|
||||
mobileFrag.appendChild(card);
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle correct view
|
||||
mobileList.style.display = isMobile ? "block" : "none";
|
||||
|
||||
countSpan.textContent = nodes.length;
|
||||
|
||||
if (shouldRenderTable) {
|
||||
tbody.appendChild(tableFrag);
|
||||
} else {
|
||||
mobileList.appendChild(mobileFrag);
|
||||
}
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
@@ -373,63 +671,103 @@ document.addEventListener("DOMContentLoaded", async function() {
|
||||
hwFilter.value = "";
|
||||
firmwareFilter.value = "";
|
||||
searchBox.value = "";
|
||||
|
||||
sortColumn = "short_name";
|
||||
sortAsc = true;
|
||||
showOnlyFavorites = false;
|
||||
|
||||
favoritesBtn.textContent = "⭐ Show Favorites";
|
||||
favoritesBtn.classList.remove("active");
|
||||
renderTable(allNodes);
|
||||
|
||||
applyFilters();
|
||||
updateSortIcons();
|
||||
}
|
||||
|
||||
function exportToCSV() {
|
||||
const rows = [];
|
||||
const headersText = Array.from(headers).map(th => `"${th.innerText.replace(/▲|▼/g,'')}"`);
|
||||
rows.push(headersText.join(","));
|
||||
const headerList = Array.from(headers).map(h =>
|
||||
`"${h.innerText.replace(/▲|▼/g,'')}"`
|
||||
);
|
||||
rows.push(headerList.join(","));
|
||||
|
||||
const visibleRows = tbody.querySelectorAll("tr");
|
||||
visibleRows.forEach(tr => {
|
||||
if (tr.children.length === 9) {
|
||||
const row = Array.from(tr.children).map(td => `"${td.innerText.replace(/"/g, '""')}"`);
|
||||
rows.push(row.join(","));
|
||||
}
|
||||
const trs = tbody.querySelectorAll("tr");
|
||||
trs.forEach(tr => {
|
||||
const cells = Array.from(tr.children).map(td =>
|
||||
`"${td.innerText.replace(/"/g,'""')}"`
|
||||
);
|
||||
rows.push(cells.join(","));
|
||||
});
|
||||
|
||||
const csvContent = "data:text/csv;charset=utf-8,\uFEFF" + rows.join("\n");
|
||||
const link = document.createElement("a");
|
||||
link.href = encodeURI(csvContent);
|
||||
const dateStr = new Date().toISOString().slice(0,10);
|
||||
link.download = `nodes_list_${dateStr}.csv`;
|
||||
link.click();
|
||||
const csv = "data:text/csv;charset=utf-8,\uFEFF" + rows.join("\n");
|
||||
const a = document.createElement("a");
|
||||
a.href = encodeURI(csv);
|
||||
a.download = "nodelist.csv";
|
||||
a.click();
|
||||
}
|
||||
|
||||
function sortNodes(nodes, key, asc) {
|
||||
return [...nodes].sort((a, b) => {
|
||||
let valA = a[key] || "";
|
||||
let valB = b[key] || "";
|
||||
let A = a[key];
|
||||
let B = b[key];
|
||||
|
||||
if (key === "last_lat" || key === "last_long") {
|
||||
valA = Number(valA) || 0;
|
||||
valB = Number(valB) || 0;
|
||||
}
|
||||
if (key === "last_update") {
|
||||
valA = valA ? new Date(valA).getTime() : 0;
|
||||
valB = valB ? new Date(valB).getTime() : 0;
|
||||
if (key === "last_seen_us") {
|
||||
A = A || 0;
|
||||
B = B || 0;
|
||||
}
|
||||
|
||||
if (valA < valB) return asc ? -1 : 1;
|
||||
if (valA > valB) return asc ? 1 : -1;
|
||||
// Normalize strings for stable sorting
|
||||
if (typeof A === "string") A = A.toLowerCase();
|
||||
if (typeof B === "string") B = B.toLowerCase();
|
||||
|
||||
if (A < B) return asc ? -1 : 1;
|
||||
if (A > B) return asc ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortIcons() {
|
||||
headers.forEach((th, index) => {
|
||||
headers.forEach((th, i) => {
|
||||
const span = th.querySelector(".sort-icon");
|
||||
if (!span) return;
|
||||
span.textContent = (keyMap[index] === sortColumn) ? (sortAsc ? "▲" : "▼") : "";
|
||||
span.textContent =
|
||||
keyMap[i] === sortColumn ? (sortAsc ? "▲" : "▼") : "";
|
||||
});
|
||||
}
|
||||
|
||||
function setStatus(message) {
|
||||
if (!statusSpan) return;
|
||||
if (statusHideTimer) {
|
||||
clearTimeout(statusHideTimer);
|
||||
statusHideTimer = null;
|
||||
}
|
||||
|
||||
if (message) {
|
||||
statusShownAt = Date.now();
|
||||
console.log("[nodelist] status:", message);
|
||||
statusSpan.textContent = message;
|
||||
statusSpan.classList.add("active");
|
||||
isBusy = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsed = Date.now() - statusShownAt;
|
||||
const remaining = Math.max(0, minStatusMs - elapsed);
|
||||
if (remaining > 0) {
|
||||
statusHideTimer = setTimeout(() => {
|
||||
statusHideTimer = null;
|
||||
console.log("[nodelist] status: cleared");
|
||||
statusSpan.textContent = "";
|
||||
statusSpan.classList.remove("active");
|
||||
isBusy = false;
|
||||
}, remaining);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("[nodelist] status: cleared");
|
||||
statusSpan.textContent = "";
|
||||
statusSpan.classList.remove("active");
|
||||
isBusy = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,69 +1,550 @@
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
{% set from_me = packet.from_node_id == node_id %}
|
||||
{% set to_me = packet.to_node_id == node_id %}
|
||||
<span {% if from_me %} class="fw-bold" {% endif %}>
|
||||
{{packet.from_node.long_name}}(
|
||||
{%- if not from_me -%}
|
||||
<a href="/node_search?q={{packet.from_node_id|node_id_to_hex}}">
|
||||
{%- endif -%}
|
||||
{{packet.from_node_id|node_id_to_hex}}
|
||||
{%- if not from_me -%}
|
||||
</a>
|
||||
{%- endif -%}
|
||||
)
|
||||
</span>
|
||||
<span {% if to_me %} class="fw-bold" {% endif %}>
|
||||
{{packet.to_node.long_name}}(
|
||||
{%- if not to_me -%}
|
||||
<a hx-target="#node" href="/node_search?q={{packet.to_node_id|node_id_to_hex}}">
|
||||
{%- endif -%}
|
||||
{{packet.to_node_id|node_id_to_hex}}
|
||||
{%- if not to_me -%}
|
||||
</a>
|
||||
{%- endif -%}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
{{packet.id}}
|
||||
<a href="/packet/{{packet.id}}">🔎</a>
|
||||
</div>
|
||||
<div class="card-text text-start">
|
||||
<dl>
|
||||
<dt>Import Time</dt>
|
||||
<dd>{{packet.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y')}}</dd>
|
||||
<dt>packet</dt>
|
||||
<dd><pre>{{packet.data}}</pre></dd>
|
||||
<dt>payload</dt>
|
||||
<dd>
|
||||
{% if packet.pretty_payload %}
|
||||
<div>{{packet.pretty_payload}}</div>
|
||||
{% endif %}
|
||||
{% if packet.raw_mesh_packet and packet.raw_mesh_packet.decoded and packet.raw_mesh_packet.decoded.reply_id %}
|
||||
<i>(Replying to: <a href="/packet/{{ packet.raw_mesh_packet.decoded.reply_id }}">{{ packet.raw_mesh_packet.decoded.reply_id }}</a>)</i>
|
||||
{% endif %}
|
||||
{% if packet.raw_mesh_packet.decoded and packet.raw_mesh_packet.decoded.portnum == 70 %}
|
||||
<ul>
|
||||
{% for node_id in packet.raw_payload.route %}
|
||||
<li><a
|
||||
href="/packet_list/{{node_id}}"
|
||||
>
|
||||
{{node_id | node_id_to_hex}}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if packet.raw_mesh_packet.decoded.want_response %}
|
||||
<a href="/graph/traceroute/{{packet.id}}">graph</a>
|
||||
{% else %}
|
||||
<a href="/graph/traceroute/{{packet.raw_mesh_packet.decoded.request_id}}">graph</a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<pre>{{packet.payload}}</pre>
|
||||
</dd>
|
||||
</dl>
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Packet Details{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
{{ super() }}
|
||||
<style>
|
||||
|
||||
/* --- Packet page container --- */
|
||||
.packet-container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 15px;
|
||||
font-family: "JetBrains Mono", monospace;
|
||||
}
|
||||
|
||||
/* --- Packet Details Card --- */
|
||||
.packet-card .card-body { padding: 26px 30px; }
|
||||
.packet-card {
|
||||
background-color: #1e1f22;
|
||||
border: 1px solid #3a3a3a;
|
||||
border-radius: 12px;
|
||||
color: #ddd;
|
||||
margin-top: 35px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.35);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.packet-card .card-header {
|
||||
background: linear-gradient(90deg, #2c2f35, #25262a);
|
||||
border-bottom: 1px solid #3f3f3f;
|
||||
font-weight: 600;
|
||||
font-size: 1.1em;
|
||||
padding: 14px 18px;
|
||||
color: #e2e6ea;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* --- Map --- */
|
||||
#map {
|
||||
width: 100%;
|
||||
height: 640px;
|
||||
border-radius: 10px;
|
||||
margin-top: 20px;
|
||||
border: 1px solid #333;
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* --- SOURCE MARKER --- */
|
||||
.source-marker {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
background: rgba(255,0,0,0.55);
|
||||
border: 3px solid #ff0000;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 6px rgba(255,0,0,0.7);
|
||||
}
|
||||
|
||||
/* --- Seen Table --- */
|
||||
.seen-table {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 6px;
|
||||
font-size: 0.92em;
|
||||
}
|
||||
.seen-table thead th {
|
||||
background-color: #2a2b2f;
|
||||
color: #e2e2e2;
|
||||
padding: 10px 12px;
|
||||
border: none !important;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75em;
|
||||
}
|
||||
.seen-table tbody td {
|
||||
background: #323338;
|
||||
color: #f0f0f0;
|
||||
border-top: 1px solid #4a4c4f !important;
|
||||
border-bottom: 1px solid #4a4c4f !important;
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
.seen-table tbody tr:hover td { background-color: #3a3c41 !important; }
|
||||
.seen-table tbody tr td:first-child {
|
||||
border-left: 1px solid #4a4c4f;
|
||||
border-top-left-radius: 8px;
|
||||
border-bottom-left-radius: 8px;
|
||||
}
|
||||
.seen-table tbody tr td:last-child {
|
||||
border-right: 1px solid #4a4c4f;
|
||||
border-top-right-radius: 8px;
|
||||
border-bottom-right-radius: 8px;
|
||||
}
|
||||
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="container mt-4 mb-5 packet-container">
|
||||
|
||||
<div id="loading" data-translate-lang="loading">Loading packet information...</div>
|
||||
<div id="packet-card" class="packet-card d-none"></div>
|
||||
|
||||
<div id="map"></div>
|
||||
|
||||
<div id="seen-container" class="mt-4 d-none">
|
||||
<h5 style="color:#ccc; margin:15px 0 10px 0;">
|
||||
📡 <span data-translate-lang="seen_by">Seen By</span>
|
||||
<span id="seen-count" style="color:#4da6ff;"></span>
|
||||
</h5>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-dark table-sm seen-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-translate-lang="gateway">Gateway</th>
|
||||
<th data-translate-lang="rssi">RSSI</th>
|
||||
<th data-translate-lang="snr">SNR</th>
|
||||
<th data-translate-lang="hops">Hops</th>
|
||||
<th data-translate-lang="channel">Channel</th>
|
||||
<th data-translate-lang="time">Time</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="seen-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
/* ======================================================
|
||||
PACKET PAGE TRANSLATION
|
||||
====================================================== */
|
||||
let packetTranslations = {};
|
||||
|
||||
async function loadTranslationsPacket() {
|
||||
try {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const lang = cfg?.site?.language || "en";
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=packet`);
|
||||
packetTranslations = await res.json();
|
||||
applyTranslationsPacket(packetTranslations);
|
||||
} catch (err) {
|
||||
console.error("Packet translations failed:", err);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTranslationsPacket(dict, root = document) {
|
||||
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if (dict[key]) el.textContent = dict[key];
|
||||
});
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
PACKET PAGE MAIN
|
||||
====================================================== */
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
|
||||
await loadTranslationsPacket(); // <-- IMPORTANT
|
||||
|
||||
const packetCard = document.getElementById("packet-card");
|
||||
const loading = document.getElementById("loading");
|
||||
const mapDiv = document.getElementById("map");
|
||||
const seenContainer = document.getElementById("seen-container");
|
||||
const seenTableBody = document.getElementById("seen-table-body");
|
||||
const seenCountSpan = document.getElementById("seen-count");
|
||||
|
||||
/* ---------------------------------------------
|
||||
Identify packet ID
|
||||
----------------------------------------------*/
|
||||
const match = window.location.pathname.match(/\/packet\/(\d+)/);
|
||||
if (!match) {
|
||||
loading.textContent = packetTranslations.invalid_url || "Invalid packet URL";
|
||||
return;
|
||||
}
|
||||
const packetId = match[1];
|
||||
|
||||
/* PORT LABELS (NOT TRANSLATED) */
|
||||
const PORT_NAMES = {
|
||||
0:"UNKNOWN APP",
|
||||
1:"Text",
|
||||
3:"Position",
|
||||
4:"Node Info",
|
||||
5:"Routing",
|
||||
6:"Admin",
|
||||
67:"Telemetry",
|
||||
70:"Traceroute",
|
||||
71:"Neighbor"
|
||||
};
|
||||
|
||||
/* ---------------------------------------------
|
||||
Fetch packet
|
||||
----------------------------------------------*/
|
||||
const packetRes = await fetch(`/api/packets?packet_id=${packetId}`);
|
||||
const packetData = await packetRes.json();
|
||||
if (!packetData.packets.length) {
|
||||
loading.textContent = packetTranslations.not_found || "Packet not found.";
|
||||
return;
|
||||
}
|
||||
const p = packetData.packets[0];
|
||||
|
||||
/* ---------------------------------------------
|
||||
Load nodes for names & positions
|
||||
----------------------------------------------*/
|
||||
const nodesRes = await fetch("/api/nodes");
|
||||
const nodesData = await nodesRes.json();
|
||||
const nodeLookup = {};
|
||||
(nodesData.nodes || []).forEach(n => nodeLookup[n.node_id] = n);
|
||||
|
||||
const fromNodeObj = nodeLookup[p.from_node_id];
|
||||
const toNodeObj = nodeLookup[p.to_node_id];
|
||||
|
||||
const fromNodeLabel = fromNodeObj?.long_name || p.from_node_id;
|
||||
|
||||
const toNodeLabel =
|
||||
p.to_node_id == 4294967295
|
||||
? (packetTranslations.all_broadcast || "All")
|
||||
: (toNodeObj?.long_name || p.to_node_id);
|
||||
|
||||
/* ---------------------------------------------
|
||||
Parse payload for lat/lon
|
||||
----------------------------------------------*/
|
||||
let lat = null, lon = null;
|
||||
const parsed = {};
|
||||
|
||||
if (p.payload?.includes(":")) {
|
||||
p.payload.split("\n").forEach(line => {
|
||||
const [k, v] = line.split(":").map(x=>x.trim());
|
||||
if (k && v !== undefined) {
|
||||
parsed[k] = v;
|
||||
if (k === "latitude_i") lat = Number(v) / 1e7;
|
||||
if (k === "longitude_i") lon = Number(v) / 1e7;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
Render card
|
||||
----------------------------------------------*/
|
||||
const time = p.import_time_us
|
||||
? new Date(p.import_time_us / 1000).toLocaleString()
|
||||
: "—";
|
||||
|
||||
const telemetryExtras = [];
|
||||
if (parsed.PDOP) telemetryExtras.push(`PDOP: ${parsed.PDOP}`);
|
||||
if (parsed.sats_in_view) telemetryExtras.push(`Sats: ${parsed.sats_in_view}`);
|
||||
if (parsed.ground_speed) telemetryExtras.push(`Speed: ${parsed.ground_speed}`);
|
||||
if (parsed.altitude) telemetryExtras.push(`Altitude: ${parsed.altitude}`);
|
||||
|
||||
packetCard.innerHTML = `
|
||||
<div class="card-header">
|
||||
<span>
|
||||
<span data-translate-lang="packet_id_label">${packetTranslations.packet_id_label || "Packet ID:"}</span>
|
||||
<i>${p.id}</i>
|
||||
</span>
|
||||
<small>${time}</small>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<dl>
|
||||
<dt data-translate-lang="from_node">${packetTranslations.from_node || "From Node"}:</dt>
|
||||
<dd><a href="/node/${p.from_node_id}">${fromNodeLabel}</a></dd>
|
||||
|
||||
<dt data-translate-lang="to_node">${packetTranslations.to_node || "To Node"}:</dt>
|
||||
<dd>${
|
||||
p.to_node_id === 4294967295
|
||||
? `<i data-translate-lang="all_broadcast">${packetTranslations.all_broadcast || "All"}</i>`
|
||||
: p.to_node_id === 1
|
||||
? `<i data-translate-lang="direct_to_mqtt">${packetTranslations.direct_to_mqtt || "Direct to MQTT"}</i>`
|
||||
: `<a href="/node/${p.to_node_id}">${toNodeLabel}</a>`
|
||||
}</dd>
|
||||
|
||||
<dt data-translate-lang="channel">${packetTranslations.channel || "Channel"}:</dt>
|
||||
<dd>${p.channel ?? "—"}</dd>
|
||||
|
||||
<dt data-translate-lang="port">${packetTranslations.port || "Port"}:</dt>
|
||||
<dd><i>${PORT_NAMES[p.portnum] || "UNKNOWN APP"}</i> (${p.portnum})</dd>
|
||||
|
||||
<dt data-translate-lang="raw_payload">${packetTranslations.raw_payload || "From Raw Payload"}:</dt>
|
||||
<dd><pre>${escapeHtml(p.payload ?? "—")}</pre></dd>
|
||||
|
||||
${
|
||||
telemetryExtras.length
|
||||
? `<dt data-translate-lang="decoded_telemetry">${packetTranslations.decoded_telemetry || "Decoded Telemetry"}</dt>
|
||||
<dd><pre>${telemetryExtras.join("\n")}</pre></dd>`
|
||||
: ""
|
||||
}
|
||||
|
||||
${
|
||||
lat && lon
|
||||
? `<dt data-translate-lang="location">${packetTranslations.location || "Location:"}</dt>
|
||||
<dd>${lat.toFixed(6)}, ${lon.toFixed(6)}</dd>`
|
||||
: ""
|
||||
}
|
||||
</dl>
|
||||
</div>
|
||||
`;
|
||||
|
||||
loading.classList.add("d-none");
|
||||
packetCard.classList.remove("d-none");
|
||||
|
||||
/* ---------------------------------------------
|
||||
Map setup
|
||||
----------------------------------------------*/
|
||||
const map = L.map("map");
|
||||
mapDiv.style.display = "block";
|
||||
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", { maxZoom: 19 })
|
||||
.addTo(map);
|
||||
|
||||
const allBounds = [];
|
||||
|
||||
/* ---------------------------------------------
|
||||
Determine packet source location
|
||||
----------------------------------------------*/
|
||||
let srcLat = lat;
|
||||
let srcLon = lon;
|
||||
|
||||
if ((!srcLat || !srcLon) && fromNodeObj?.last_lat && fromNodeObj?.last_long) {
|
||||
srcLat = fromNodeObj.last_lat / 1e7;
|
||||
srcLon = fromNodeObj.last_long / 1e7;
|
||||
}
|
||||
|
||||
if (srcLat && srcLon) {
|
||||
allBounds.push([srcLat, srcLon]);
|
||||
|
||||
const sourceIcon = L.divIcon({
|
||||
html: `<div class="source-marker"></div>`,
|
||||
className: "",
|
||||
iconSize: [26, 26],
|
||||
iconAnchor: [13, 13]
|
||||
});
|
||||
|
||||
const sourceMarker = L.marker([srcLat, srcLon], {
|
||||
icon: sourceIcon,
|
||||
zIndexOffset: 9999
|
||||
}).addTo(map);
|
||||
|
||||
sourceMarker.bindPopup(`
|
||||
<div style="font-size:0.9em">
|
||||
<b data-translate-lang="packet_source">${packetTranslations.packet_source || "Packet Source"}</b><br>
|
||||
Lat: ${srcLat.toFixed(6)}<br>
|
||||
Lon: ${srcLon.toFixed(6)}<br>
|
||||
<span data-translate-lang="from_node">${packetTranslations.from_node || "From Node:"}</span> ${fromNodeLabel}<br>
|
||||
<span data-translate-lang="channel">${packetTranslations.channel || "Channel:"}</span> ${p.channel ?? "—"}<br>
|
||||
<span data-translate-lang="port">${packetTranslations.port || "Port:"}</span> ${PORT_NAMES[p.portnum] || "UNKNOWN"} (${p.portnum})
|
||||
</div>
|
||||
`);
|
||||
} else {
|
||||
map.setView([0,0], 2);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
Colors for hops (warm → cold)
|
||||
----------------------------------------------*/
|
||||
function hopColor(hopValue){
|
||||
const colors = [
|
||||
"#ff3b30","#ff6b22","#ff9f0c","#ffd60a",
|
||||
"#87d957","#57d9c4","#3db2ff","#1e63ff"
|
||||
];
|
||||
let h = Number(hopValue);
|
||||
if (isNaN(h)) return "#aaa";
|
||||
return colors[Math.min(Math.max(h, 0), 7)];
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
Distance helper
|
||||
----------------------------------------------*/
|
||||
function haversine(lat1, lon1, lat2, lon2){
|
||||
const R = 6371;
|
||||
const dLat = (lat2-lat1)*Math.PI/180;
|
||||
const dLon = (lon2-lon1)*Math.PI/180;
|
||||
const a = Math.sin(dLat/2)**2 +
|
||||
Math.cos(lat1*Math.PI/180)*
|
||||
Math.cos(lat2*Math.PI/180)*
|
||||
Math.sin(dLon/2)**2;
|
||||
return R * (2*Math.atan2(Math.sqrt(a), Math.sqrt(1-a)));
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
Load packets_seen
|
||||
----------------------------------------------*/
|
||||
const seenRes = await fetch(`/api/packets_seen/${packetId}`);
|
||||
const seenData = await seenRes.json();
|
||||
const seenList = seenData.seen ?? [];
|
||||
|
||||
/* ---------------------------------------------
|
||||
Sort by hop count (highest first)
|
||||
----------------------------------------------*/
|
||||
const seenSorted = seenList.slice().sort((a,b)=>{
|
||||
const ha = (a.hop_start ?? 0) - (a.hop_limit ?? 0);
|
||||
const hb = (b.hop_start ?? 0) - (b.hop_limit ?? 0);
|
||||
return hb - ha;
|
||||
});
|
||||
|
||||
if (seenSorted.length){
|
||||
seenContainer.classList.remove("d-none");
|
||||
seenCountSpan.textContent = `(${seenSorted.length})`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
GROUP BY HOP COUNT
|
||||
----------------------------------------------*/
|
||||
const hopGroups = {};
|
||||
|
||||
seenSorted.forEach(s => {
|
||||
const hopValue = Math.max(
|
||||
0,
|
||||
(s.hop_start ?? 0) - (s.hop_limit ?? 0)
|
||||
);
|
||||
if (!hopGroups[hopValue]) hopGroups[hopValue] = [];
|
||||
hopGroups[hopValue].push(s);
|
||||
});
|
||||
|
||||
/* ---------------------------------------------
|
||||
Render grouped gateway table + map markers
|
||||
----------------------------------------------*/
|
||||
seenTableBody.innerHTML = Object.keys(hopGroups)
|
||||
.sort((a,b) => Number(a) - Number(b)) // 0 hop first
|
||||
.map(hopKey => {
|
||||
|
||||
const hopLabel =
|
||||
hopKey === "0"
|
||||
? (packetTranslations.direct || "Direct (0 hops)")
|
||||
: `${hopKey} ${packetTranslations.hops || "hops"}`;
|
||||
|
||||
const rows = hopGroups[hopKey].map(s => {
|
||||
const node = nodeLookup[s.node_id];
|
||||
const label = node?.long_name || s.node_id;
|
||||
|
||||
const timeStr = s.import_time_us
|
||||
? new Date(s.import_time_us/1000).toLocaleTimeString()
|
||||
: "—";
|
||||
|
||||
/* ---------------- MAP MARKERS (UNCHANGED) ---------------- */
|
||||
if (node?.last_lat && node.last_long){
|
||||
const rlat = node.last_lat/1e7;
|
||||
const rlon = node.last_long/1e7;
|
||||
allBounds.push([rlat, rlon]);
|
||||
let distanceKm = null;
|
||||
if (srcLat && srcLon) {
|
||||
distanceKm = haversine(srcLat, srcLon, rlat, rlon);
|
||||
}
|
||||
const distanceMi = distanceKm !== null ? distanceKm * 0.621371 : null;
|
||||
const color = hopColor(hopKey);
|
||||
|
||||
const marker = L.marker([rlat,rlon],{
|
||||
icon: L.divIcon({
|
||||
html: `
|
||||
<div style="
|
||||
background:${color};
|
||||
width:24px; height:24px;
|
||||
border-radius:50%;
|
||||
display:flex;
|
||||
align-items:center;
|
||||
justify-content:center;
|
||||
color:white;
|
||||
font-size:11px;
|
||||
font-weight:700;
|
||||
border:2px solid rgba(0,0,0,0.35);
|
||||
box-shadow:0 0 5px rgba(0,0,0,0.45);
|
||||
">${hopKey}</div>`,
|
||||
className: "",
|
||||
iconSize:[24,24],
|
||||
iconAnchor:[12,12]
|
||||
})
|
||||
}).addTo(map);
|
||||
|
||||
marker.bindPopup(`
|
||||
<div style="font-size:0.9em">
|
||||
<b>${label}</b><br>
|
||||
<span data-translate-lang="node_id_short">Node ID</span>:
|
||||
<a href="/node/${s.node_id}">${s.node_id}</a><br>
|
||||
HW: ${node?.hw_model ?? "—"}<br>
|
||||
<span data-translate-lang="channel">Channel</span>: ${s.channel ?? "—"}<br>
|
||||
${
|
||||
distanceKm !== null
|
||||
? `<span data-translate-lang="distance">Distance</span>:
|
||||
${distanceKm.toFixed(1)} km / ${distanceMi.toFixed(1)} mi<br>`
|
||||
: ""
|
||||
}
|
||||
|
||||
<br>
|
||||
|
||||
<b data-translate-lang="signal">Signal</b><br>
|
||||
RSSI: ${s.rx_rssi ?? "—"}<br>
|
||||
SNR: ${s.rx_snr ?? "—"}<br><br>
|
||||
|
||||
<b data-translate-lang="hops">Hops</b>: ${hopKey}
|
||||
</div>
|
||||
`);
|
||||
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><a href="/node/${s.node_id}">${label}</a></td>
|
||||
<td>${s.rx_rssi ?? "—"}</td>
|
||||
<td>${s.rx_snr ?? "—"}</td>
|
||||
<td>${hopKey}</td>
|
||||
<td>${s.channel ?? "—"}</td>
|
||||
<td>${timeStr}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td colspan="6"
|
||||
style="
|
||||
background:#1f2327;
|
||||
font-weight:700;
|
||||
color:#9ecbff;
|
||||
border-top:1px solid #444;
|
||||
padding:8px 12px;
|
||||
">
|
||||
🔁 ${hopLabel} (${hopGroups[hopKey].length})
|
||||
</td>
|
||||
</tr>
|
||||
${rows}
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
|
||||
/* ---------------------------------------------
|
||||
Fit map around all markers
|
||||
----------------------------------------------*/
|
||||
if (allBounds.length > 0){
|
||||
map.fitBounds(allBounds, { padding:[40,40] });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------
|
||||
Escape HTML helper
|
||||
----------------------------------------------*/
|
||||
function escapeHtml(unsafe) {
|
||||
return (unsafe ?? "").replace(/[&<"'>]/g, m => ({
|
||||
"&":"&",
|
||||
"<":"<",
|
||||
">":">",
|
||||
"\"":""",
|
||||
"'":"'"
|
||||
})[m]);
|
||||
}
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
<div id="details_map"></div>
|
||||
|
||||
{% for seen in packets_seen %}
|
||||
<div class="card mt-2">
|
||||
<div class="card-header">
|
||||
{{seen.node.long_name}}(
|
||||
<a hx-target="#node" href="/node_search?q={{seen.node_id|node_id_to_hex}}">
|
||||
{{seen.node_id|node_id_to_hex}}
|
||||
</a>
|
||||
)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="card-text text-start">
|
||||
<dl>
|
||||
<dt>Import Time</dt>
|
||||
<dd>{{seen.import_time.strftime('%-I:%M:%S %p - %m-%d-%Y')}}</dd>
|
||||
<dt>rx_time</dt>
|
||||
<dd>{{seen.rx_time|format_timestamp}}</dd>
|
||||
<dt>hop_limit</dt>
|
||||
<dd>{{seen.hop_limit}}</dd>
|
||||
<dt>hop_start</dt>
|
||||
<dd>{{seen.hop_start}}</dd>
|
||||
<dt>channel</dt>
|
||||
<dd>{{seen.channel}}</dd>
|
||||
<dt>rx_snr</dt>
|
||||
<dd>{{seen.rx_snr}}</dd>
|
||||
<dt>rx_rssi</dt>
|
||||
<dd>{{seen.rx_rssi}}</dd>
|
||||
<dt>topic</dt>
|
||||
<dd>{{seen.topic}}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
{% if map_center %}
|
||||
<script>
|
||||
var details_map = L.map('details_map').setView({{ map_center | tojson }}, 8);
|
||||
var markers = L.featureGroup();
|
||||
markers.addTo(details_map);
|
||||
|
||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
maxZoom: 15,
|
||||
attribution: '© <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}).addTo(details_map);
|
||||
|
||||
function getDistanceInMiles(latlng1, latlng2) {
|
||||
var meters = latlng1.distanceTo(latlng2);
|
||||
return meters * 0.000621371;
|
||||
}
|
||||
|
||||
{% if from_node_cord %}
|
||||
var fromNodeLatLng = L.latLng({{ from_node_cord | tojson }});
|
||||
var fromNode = L.circleMarker(fromNodeLatLng, {
|
||||
radius: 10,
|
||||
color: 'red',
|
||||
weight: 1,
|
||||
fillColor: 'red',
|
||||
fillOpacity: .4
|
||||
}).addTo(markers);
|
||||
|
||||
fromNode.bindPopup(`
|
||||
Sent by: <b>{{node.long_name}}</b><br/>
|
||||
<b>Short:</b> {{node.short_name}}<br/>
|
||||
<b>Channel:</b> {{node.channel}}<br/>
|
||||
<b>Hardware:</b> {{node.hw_model}}<br/>
|
||||
<b>Role:</b> {{node.role}}<br/>
|
||||
<b>Firmware:</b> {{node.firmware}}<br/>
|
||||
<b>Coordinates:</b> [{{node.last_lat}}, {{node.last_long}}]
|
||||
`, { permanent: false, direction: 'top', opacity: 0.9 });
|
||||
{% endif %}
|
||||
|
||||
{% for u in uplinked_nodes %}
|
||||
var uplinkNodeLatLng = L.latLng([{{ u.lat }}, {{ u.long }}]);
|
||||
|
||||
{% if from_node_cord %}
|
||||
var distanceMiles = getDistanceInMiles(fromNodeLatLng, uplinkNodeLatLng).toFixed(1);
|
||||
{% endif %}
|
||||
|
||||
var node = L.marker(uplinkNodeLatLng, {
|
||||
icon: L.divIcon({
|
||||
className: 'text-icon',
|
||||
html: `<div style="font-size: 12px; color: white; font-weight: bold; display: flex; justify-content: center; align-items: center; height: 16px; width: 16px; border-radius: 50%; background-color: blue; border: 1px solid blue;">{{u.hops}}</div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8]
|
||||
})
|
||||
}).addTo(markers);
|
||||
node.setZIndexOffset({{u.hops}}*-1);
|
||||
|
||||
node.bindPopup(`
|
||||
Heard by: <b>{{u.long_name}}</b><br>
|
||||
<b>{{ u.short_name }}</b><br/>
|
||||
<b>Hops:</b> {{ u.hops }}<br/>
|
||||
<b>SNR:</b> {{ u.snr }}<br/>
|
||||
<b>RSSI:</b> {{ u.rssi }}<br/>
|
||||
{% if from_node_cord %}
|
||||
<b>Distance:</b> ${distanceMiles} miles <br/>
|
||||
{% endif %}
|
||||
<b>Coordinates:</b> [{{u.lat}}, {{u.long}}]
|
||||
`, { permanent: false, direction: 'top', opacity: 0.9 });
|
||||
{% endfor %}
|
||||
|
||||
if (markers.getLayers().length > 0) {
|
||||
details_map.fitBounds(markers.getBounds().pad(0.1), { animate: true });
|
||||
}
|
||||
|
||||
var legend = L.control({ position: 'bottomleft' });
|
||||
legend.onAdd = function(map) {
|
||||
var div = L.DomUtil.create('div', 'info legend');
|
||||
div.style.background = 'white';
|
||||
div.style.padding = '8px';
|
||||
div.style.border = '1px solid black';
|
||||
div.style.borderRadius = '5px';
|
||||
div.style.boxShadow = '0 0 5px rgba(0,0,0,0.3)';
|
||||
div.style.color = 'black';
|
||||
div.style.textAlign = 'left';
|
||||
div.innerHTML = `
|
||||
<b>Legend</b><br>
|
||||
<svg width="20" height="20">
|
||||
<circle cx="8" cy="8" r="6" fill="blue" stroke="blue" stroke-width="1" fill-opacity="0.9"/>
|
||||
</svg> Receiving Node (Number is hop count)<br>
|
||||
<svg width="20" height="20">
|
||||
<circle cx="10" cy="10" r="8" fill="red" stroke="red" stroke-width="1" fill-opacity="0.4"/>
|
||||
</svg> Sending Node<br>
|
||||
`;
|
||||
return div;
|
||||
};
|
||||
legend.addTo(details_map);
|
||||
</script>
|
||||
|
||||
{% endif %}
|
||||
@@ -1,24 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
{% block css %}
|
||||
|
||||
/* Set the maximum width of the page to 900px */
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto; /* Center the content horizontally */
|
||||
}
|
||||
{% endblock %}
|
||||
{% block body %}
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div>
|
||||
{% include 'packet.html' %}
|
||||
</div>
|
||||
<div
|
||||
id="packet_details"
|
||||
hx-get="/packet_details/{{packet.id}}"
|
||||
hx-trigger="load"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,7 +0,0 @@
|
||||
<div class="col" id="packet_list">
|
||||
{% for packet in packets %}
|
||||
{% include 'packet.html' %}
|
||||
{% else %}
|
||||
No packets found.
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
|
||||
{% block body %}
|
||||
|
||||
{% include "search_form.html" %}
|
||||
|
||||
<ul>
|
||||
{% for node in nodes %}
|
||||
<li>
|
||||
<a href="/packet_list/{{node.node_id}}?{{query_string}}">
|
||||
{{node.node_id | node_id_to_hex}}
|
||||
{% if node.long_name %}
|
||||
{{node.short_name}} — {{node.long_name}}
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
{% endblock %}
|
||||
@@ -1,44 +0,0 @@
|
||||
<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"
|
||||
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"
|
||||
/>
|
||||
{% include "datalist.html" %}
|
||||
{% set options = {
|
||||
1: "Text Message",
|
||||
3: "Position",
|
||||
4: "Node Info",
|
||||
67: "Telemetry",
|
||||
70: "Traceroute",
|
||||
71: "Neighbor Info",
|
||||
}
|
||||
%}
|
||||
<select name="portnum" class="col-2 m-2">
|
||||
<option
|
||||
value = ""
|
||||
{% if portnum not in options %}selected{% endif %}
|
||||
>All</option>
|
||||
{% for value, name in options.items() %}
|
||||
<option
|
||||
value="{{value}}"
|
||||
{% if value == portnum %}selected{% endif %}
|
||||
>{{ name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input type="submit" value="Go to Node" class="col-2 m-2" />
|
||||
</div>
|
||||
</form>
|
||||
@@ -93,26 +93,31 @@
|
||||
|
||||
{% block body %}
|
||||
<div class="main-container">
|
||||
<h2 class="main-header" data-translate-lang="mesh_stats_summary">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>
|
||||
|
||||
<!-- Summary cards now fully driven by API + JS -->
|
||||
<div class="summary-container" style="display:flex; justify-content:space-between; gap:10px; margin-bottom:20px;">
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p data-translate-lang="total_nodes">Total Nodes</p>
|
||||
<div class="summary-count">{{ "{:,}".format(total_nodes) }}</div>
|
||||
<div class="summary-count" id="summary_nodes">0</div>
|
||||
</div>
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p data-translate-lang="total_packets">Total Packets</p>
|
||||
<div class="summary-count">{{ "{:,}".format(total_packets) }}</div>
|
||||
<div class="summary-count" id="summary_packets">0</div>
|
||||
</div>
|
||||
<div class="summary-card" style="flex:1;">
|
||||
<p data-translate-lang="total_packets_seen">Total Packets Seen</p>
|
||||
<div class="summary-count">{{ "{:,}".format(total_packets_seen) }}</div>
|
||||
<div class="summary-count" id="summary_seen">0</div>
|
||||
</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>
|
||||
<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>
|
||||
@@ -121,7 +126,9 @@
|
||||
|
||||
<!-- 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>
|
||||
<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>
|
||||
@@ -131,7 +138,9 @@
|
||||
</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>
|
||||
<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>
|
||||
@@ -140,7 +149,9 @@
|
||||
|
||||
<!-- Hourly Charts -->
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="packets_per_hour_all">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" 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>
|
||||
@@ -148,7 +159,9 @@
|
||||
</div>
|
||||
|
||||
<div class="card-section">
|
||||
<p class="section-header" data-translate-lang="packets_per_hour_text">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" 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>
|
||||
@@ -214,17 +227,123 @@ 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 fetchChannels(){ try{ const res = await fetch("/api/channels"); const json = await res.json(); return json.channels || [];}catch{return [];} }
|
||||
async function fetchNodes(){
|
||||
try{
|
||||
const res=await fetch("/api/nodes");
|
||||
const json=await res.json();
|
||||
return json.nodes||[];
|
||||
}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; }
|
||||
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;
|
||||
}
|
||||
|
||||
// --- Chart Rendering ---
|
||||
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 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); 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; }
|
||||
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) {
|
||||
@@ -234,8 +353,10 @@ 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);
|
||||
@@ -250,40 +371,102 @@ let chartHwModel, chartRole, chartChannel;
|
||||
let chartPacketTypes;
|
||||
|
||||
async function init(){
|
||||
// Channel selector
|
||||
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 all ports
|
||||
const dailyAllData=await fetchStats('day',14);
|
||||
updateTotalCount('total_daily_all',dailyAllData);
|
||||
chartDailyAll=renderChart('chart_daily_all',dailyAllData,'line','#66bb6a');
|
||||
|
||||
// Daily port 1
|
||||
const dailyPort1Data=await fetchStats('day',14,1);
|
||||
updateTotalCount('total_daily_portnum_1',dailyPort1Data);
|
||||
chartDailyPortnum1=renderChart('chart_daily_portnum_1',dailyPort1Data,'bar','#ff5722');
|
||||
|
||||
// Hourly all ports
|
||||
const hourlyAllData=await fetchStats('hour',24);
|
||||
updateTotalCount('total_hourly_all',hourlyAllData);
|
||||
chartHourlyAll=renderChart('chart_hourly_all',hourlyAllData,'bar','#03dac6');
|
||||
|
||||
// Hourly per port
|
||||
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]); }
|
||||
|
||||
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]);
|
||||
}
|
||||
|
||||
// Nodes for breakdown + summary node count
|
||||
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");
|
||||
|
||||
const summaryNodesEl = document.getElementById("summary_nodes");
|
||||
if (summaryNodesEl) {
|
||||
summaryNodesEl.textContent = nodes.length.toLocaleString();
|
||||
}
|
||||
|
||||
// Packet types pie
|
||||
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)");
|
||||
|
||||
// Total packet + total seen from /api/stats/count
|
||||
try {
|
||||
const countsRes = await fetch("/api/stats/count");
|
||||
if (countsRes.ok) {
|
||||
const countsJson = await countsRes.json();
|
||||
const elPackets = document.getElementById("summary_packets");
|
||||
const elSeen = document.getElementById("summary_seen");
|
||||
if (elPackets) {
|
||||
elPackets.textContent = (countsJson.total_packets || 0).toLocaleString();
|
||||
}
|
||||
if (elSeen) {
|
||||
elSeen.textContent = (countsJson.total_seen || 0).toLocaleString();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load /api/stats/count:", err);
|
||||
}
|
||||
}
|
||||
|
||||
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());
|
||||
});
|
||||
|
||||
const modal=document.getElementById("chartModal");
|
||||
const modalChartEl=document.getElementById("modalChart");
|
||||
@@ -345,31 +528,51 @@ document.querySelectorAll(".export-btn").forEach(btn=>{
|
||||
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)");
|
||||
});
|
||||
|
||||
// Kick everything off
|
||||
init();
|
||||
|
||||
// --- Translation Loader ---
|
||||
async function loadTranslations() {
|
||||
const langCode = "{{ site_config.get('site', {}).get('language','en') }}";
|
||||
// --- Load config and translations ---
|
||||
async function loadConfigAndTranslations() {
|
||||
let langCode = "en";
|
||||
try {
|
||||
const res = await fetch(`/api/lang?lang=${langCode}§ion=stats`);
|
||||
window.statsTranslations = await res.json();
|
||||
} catch(err){
|
||||
const resConfig = await fetch("/api/config");
|
||||
const cfg = await resConfig.json();
|
||||
window.site_config = cfg;
|
||||
langCode = cfg?.site?.language || "en";
|
||||
} catch(err) {
|
||||
console.error("Failed to load /api/config:", err);
|
||||
window.site_config = { site: { language: "en" } };
|
||||
}
|
||||
|
||||
try {
|
||||
const resLang = await fetch(`/api/lang?lang=${langCode}§ion=stats`);
|
||||
window.statsTranslations = await resLang.json();
|
||||
} catch(err) {
|
||||
console.error("Stats translation load failed:", err);
|
||||
window.statsTranslations = {};
|
||||
}
|
||||
}
|
||||
function applyTranslations() {
|
||||
|
||||
// Apply translations
|
||||
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);
|
||||
|
||||
// Call after init
|
||||
loadConfigAndTranslations();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block css %}
|
||||
#packet_details {
|
||||
height: 95vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.main-container, .container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-section {
|
||||
background-color: #272b2f;
|
||||
border: 1px solid #474b4e;
|
||||
padding: 15px 20px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 10px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.card-section:hover {
|
||||
background-color: #2f3338;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: 16px;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.section-value {
|
||||
font-weight: 700;
|
||||
color: #03dac6;
|
||||
}
|
||||
|
||||
.percentage {
|
||||
font-size: 12px;
|
||||
color: #ffeb3b;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.main-header {
|
||||
font-size: 22px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div class="main-container">
|
||||
<h2 class="main-header">Mesh Statistics</h2>
|
||||
|
||||
<!-- Section for Total Nodes -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">
|
||||
Total Active Nodes (24 hours): <br>
|
||||
<span class="section-value">{{ "{:,}".format(total_nodes) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section for Total Packets -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">
|
||||
Total Packets (14 days):
|
||||
<span class="section-value">{{ "{:,}".format(total_packets) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Section for Total MQTT Reports -->
|
||||
<div class="card-section">
|
||||
<p class="section-header">
|
||||
Total MQTT Reports (14 days):
|
||||
<span class="section-value">{{ "{:,}".format(total_packets_seen) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -2,287 +2,240 @@
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* General table styling */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 20px;
|
||||
}
|
||||
body { background-color: #121212; color: #ddd; }
|
||||
h1 { text-align: center; margin-top: 20px; color: #fff; }
|
||||
|
||||
table th, table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #ddd;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
table th {
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
table tbody tr:nth-child(odd) {
|
||||
background-color: #272b2f;
|
||||
}
|
||||
|
||||
table tbody tr:nth-child(even) {
|
||||
background-color: #212529;
|
||||
}
|
||||
|
||||
table tbody tr:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
table td {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
table th, table td {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
table th, table td {
|
||||
padding: 8px;
|
||||
.top-container {
|
||||
max-width: 1100px;
|
||||
margin: 25px auto;
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
text-align: center;
|
||||
color: #ddd;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
#bellCurveChart {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin-top: 30px;
|
||||
}
|
||||
.filter-bar select {
|
||||
background-color: #1f2327;
|
||||
border: 1px solid #444;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
#stats {
|
||||
text-align: center;
|
||||
margin-top: 20px;
|
||||
color: #fff;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
select {
|
||||
margin: 10px auto;
|
||||
display: block;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background-color: #333;
|
||||
color: #fff;
|
||||
border: 1px solid #555;
|
||||
}
|
||||
table th, table td {
|
||||
padding: 12px;
|
||||
border: 1px solid #444;
|
||||
text-align: left;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
table th { background-color: #333; }
|
||||
|
||||
table tbody tr:nth-child(odd) { background-color: #272b2f; }
|
||||
table tbody tr:nth-child(even) { background-color: #212529; }
|
||||
table tbody tr:hover { background-color: #555; cursor: pointer; }
|
||||
|
||||
.node-link {
|
||||
color: #9fd4ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
.node-link:hover { text-decoration: underline; }
|
||||
|
||||
.good-x { color: #81ff81; font-weight: bold; }
|
||||
.ok-x { color: #e8e86d; font-weight: bold; }
|
||||
.bad-x { color: #ff6464; font-weight: bold; }
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<h1 data-translate-lang="top_traffic_nodes">Top Traffic Nodes (last 24 hours)</h1>
|
||||
|
||||
<!-- Channel Filter Dropdown -->
|
||||
<select id="channelFilter"></select>
|
||||
<h1 data-translate-lang="top_traffic_nodes">Top Nodes Traffic</h1>
|
||||
|
||||
<div id="stats">
|
||||
<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>
|
||||
<div class="top-container">
|
||||
|
||||
<!-- Chart -->
|
||||
<div id="bellCurveChart"></div>
|
||||
<div class="filter-bar">
|
||||
<div>
|
||||
<label data-translate-lang="channel">Channel:</label>
|
||||
<select id="channelFilter"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
{% if nodes %}
|
||||
<div class="container">
|
||||
<table id="trafficTable">
|
||||
<div style="margin-bottom:10px;font-weight:bold;">
|
||||
<span data-translate-lang="showing_nodes">Showing</span>
|
||||
<span id="node-count">0</span>
|
||||
<span data-translate-lang="nodes_suffix">nodes</span>
|
||||
</div>
|
||||
|
||||
<table id="nodesTable">
|
||||
<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>
|
||||
<th data-translate-lang="long_name">Long Name</th>
|
||||
<th data-translate-lang="short_name">Short Name</th>
|
||||
<th data-translate-lang="channel">Channel</th>
|
||||
<th data-translate-lang="packets_sent">Sent (24h)</th>
|
||||
<th data-translate-lang="times_seen">Seen (24h)</th>
|
||||
<th data-translate-lang="avg_gateways">Avg Gateways</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
|
||||
<div class="pagination">
|
||||
<button id="prevPage" class="btn btn-sm btn-secondary">Prev</button>
|
||||
<span id="pageInfo"></span>
|
||||
<button id="nextPage" class="btn btn-sm btn-secondary">Next</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% else %}
|
||||
<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>
|
||||
<script>
|
||||
const nodes = {{ nodes | tojson }};
|
||||
let filteredNodes = [];
|
||||
/* ======================================================
|
||||
TRANSLATIONS
|
||||
====================================================== */
|
||||
let topTranslations = {};
|
||||
|
||||
// --- 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];
|
||||
function applyTranslationsTop(dict, root=document) {
|
||||
root.querySelectorAll("[data-translate-lang]").forEach(el => {
|
||||
const key = el.dataset.translateLang;
|
||||
if (!dict[key]) return;
|
||||
el.textContent = dict[key];
|
||||
});
|
||||
}
|
||||
|
||||
// --- Chart & Table code ---
|
||||
const chart = echarts.init(document.getElementById('bellCurveChart'));
|
||||
const meanEl = document.getElementById('mean');
|
||||
const stdEl = document.getElementById('stdDev');
|
||||
|
||||
// Populate channel dropdown
|
||||
const channelSet = new Set();
|
||||
nodes.forEach(n => channelSet.add(n.channel));
|
||||
const dropdown = document.getElementById('channelFilter');
|
||||
[...channelSet].sort().forEach(channel => {
|
||||
const option = document.createElement('option');
|
||||
option.value = channel;
|
||||
option.textContent = channel;
|
||||
if (channel === "LongFast") option.selected = true;
|
||||
dropdown.appendChild(option);
|
||||
});
|
||||
|
||||
// Filter default
|
||||
filteredNodes = nodes.filter(n => n.channel === "LongFast");
|
||||
dropdown.addEventListener('change', () => {
|
||||
const val = dropdown.value;
|
||||
filteredNodes = nodes.filter(n => n.channel === val);
|
||||
updateTable();
|
||||
updateStatsAndChart();
|
||||
});
|
||||
|
||||
// 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));
|
||||
async function loadTranslationsTop() {
|
||||
const cfg = await window._siteConfigPromise;
|
||||
const lang = cfg?.site?.language || "en";
|
||||
const res = await fetch(`/api/lang?lang=${lang}§ion=top`);
|
||||
topTranslations = await res.json();
|
||||
applyTranslationsTop(topTranslations);
|
||||
}
|
||||
|
||||
// Update table
|
||||
function updateTable() {
|
||||
const tbody = document.querySelector('#trafficTable tbody');
|
||||
/* ======================================================
|
||||
CONFIG
|
||||
====================================================== */
|
||||
const PAGE_SIZE = 20;
|
||||
let currentPage = 0;
|
||||
let totalRows = 0;
|
||||
|
||||
/* ======================================================
|
||||
HELPERS
|
||||
====================================================== */
|
||||
function avgClass(v) {
|
||||
if (v >= 10) return "good-x";
|
||||
if (v >= 2) return "ok-x";
|
||||
return "bad-x";
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
LOAD CHANNELS
|
||||
====================================================== */
|
||||
async function loadChannels() {
|
||||
const res = await fetch("/api/channels");
|
||||
const data = await res.json();
|
||||
const sel = document.getElementById("channelFilter");
|
||||
|
||||
sel.innerHTML = "";
|
||||
for (const ch of data.channels || []) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ch;
|
||||
opt.textContent = ch;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
|
||||
sel.value = "MediumFast";
|
||||
}
|
||||
|
||||
/* ======================================================
|
||||
FETCH + RENDER
|
||||
====================================================== */
|
||||
async function renderTable() {
|
||||
const tbody = document.querySelector("#nodesTable tbody");
|
||||
tbody.innerHTML = "";
|
||||
const 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>
|
||||
<td><a href="/packet_list/${node.node_id}">${node.long_name}</a></td>
|
||||
<td>${node.short_name}</td>
|
||||
<td>${node.channel}</td>
|
||||
<td><a href="/top?node_id=${node.node_id}">${node.total_packets_sent}</a></td>
|
||||
<td>${node.total_times_seen}</td>
|
||||
<td>${percent}</td>
|
||||
</tr>`;
|
||||
tbody.insertAdjacentHTML('beforeend', row);
|
||||
|
||||
const channel = document.getElementById("channelFilter").value;
|
||||
const offset = currentPage * PAGE_SIZE;
|
||||
|
||||
const url = new URL("/api/stats/top", window.location.origin);
|
||||
url.searchParams.set("limit", PAGE_SIZE);
|
||||
url.searchParams.set("offset", offset);
|
||||
if (channel) url.searchParams.set("channel", channel);
|
||||
|
||||
const res = await fetch(url);
|
||||
const data = await res.json();
|
||||
|
||||
totalRows = data.total || 0;
|
||||
|
||||
for (const n of data.nodes || []) {
|
||||
const tr = document.createElement("tr");
|
||||
tr.onclick = () => location.href = `/node/${n.node_id}`;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<a class="node-link" href="/node/${n.node_id}"
|
||||
onclick="event.stopPropagation()">
|
||||
${n.long_name || n.node_id}
|
||||
</a>
|
||||
</td>
|
||||
<td>${n.short_name || ""}</td>
|
||||
<td>${n.channel || ""}</td>
|
||||
<td>${n.sent}</td>
|
||||
<td>${n.seen}</td>
|
||||
<td><span class="${avgClass(n.avg)}">${n.avg.toFixed(1)}</span></td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / PAGE_SIZE));
|
||||
|
||||
document.getElementById("node-count").textContent = totalRows;
|
||||
document.getElementById("pageInfo").textContent =
|
||||
`Page ${currentPage + 1} / ${totalPages}`;
|
||||
|
||||
document.getElementById("prevPage").disabled = currentPage === 0;
|
||||
document.getElementById("nextPage").disabled = currentPage >= totalPages - 1;
|
||||
}
|
||||
|
||||
// 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));
|
||||
meanEl.textContent = mean.toFixed(2);
|
||||
stdEl.textContent = stdDev.toFixed(2);
|
||||
/* ======================================================
|
||||
INIT
|
||||
====================================================== */
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
await loadTranslationsTop();
|
||||
await loadChannels();
|
||||
await renderTable();
|
||||
|
||||
const min = Math.min(...timesSeen);
|
||||
const max = Math.max(...timesSeen);
|
||||
const step = (max - min) / 100;
|
||||
const xData=[], yData=[];
|
||||
for(let x=min;x<=max;x+=step){ xData.push(x); yData.push(normalDistribution(x,mean,stdDev)); }
|
||||
channelFilter.onchange = () => {
|
||||
currentPage = 0;
|
||||
renderTable();
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
prevPage.onclick = () => {
|
||||
if (currentPage > 0) {
|
||||
currentPage--;
|
||||
renderTable();
|
||||
}
|
||||
};
|
||||
|
||||
// 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].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');
|
||||
const tbody = table.tBodies[0];
|
||||
sortedRows.forEach(row=>tbody.appendChild(row));
|
||||
}
|
||||
|
||||
// Initialize
|
||||
(async ()=>{
|
||||
await loadTopTranslations();
|
||||
applyTopTranslations();
|
||||
updateTable();
|
||||
updateStatsAndChart();
|
||||
window.addEventListener('resize',()=>chart.resize());
|
||||
})();
|
||||
nextPage.onclick = () => {
|
||||
currentPage++;
|
||||
renderTable();
|
||||
};
|
||||
});
|
||||
</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 %}
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
{% block head %}
|
||||
<script src="https://cdn.jsdelivr.net/npm/echarts/dist/echarts.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<div id="mynetwork" style="width: 100%; height: 800px;"></div>
|
||||
|
||||
<script type="text/javascript">
|
||||
const chart = echarts.init(document.getElementById('mynetwork'));
|
||||
|
||||
const rawNodes = {{ chart_data['nodes'] | tojson }};
|
||||
const rawEdges = {{ chart_data['edges'] | tojson }};
|
||||
|
||||
// Build DAG layout
|
||||
const layers = {};
|
||||
const nodeDepth = {};
|
||||
|
||||
// Organize nodes into layers by hop count
|
||||
for (const edge of rawEdges) {
|
||||
const { source, target } = edge;
|
||||
if (!(source in nodeDepth)) nodeDepth[source] = 0;
|
||||
const nextDepth = nodeDepth[source] + 1;
|
||||
nodeDepth[target] = Math.max(nodeDepth[target] || 0, nextDepth);
|
||||
}
|
||||
|
||||
for (const node of rawNodes) {
|
||||
const depth = nodeDepth[node.name] || 0;
|
||||
if (!(depth in layers)) layers[depth] = [];
|
||||
layers[depth].push(node);
|
||||
}
|
||||
|
||||
// Position nodes manually
|
||||
const chartNodes = [];
|
||||
const layerKeys = Object.keys(layers).sort((a, b) => +a - +b);
|
||||
const verticalSpacing = 100;
|
||||
const horizontalSpacing = 180;
|
||||
|
||||
layerKeys.forEach((depth, layerIndex) => {
|
||||
const layer = layers[depth];
|
||||
const y = layerIndex * verticalSpacing;
|
||||
const xStart = -(layer.length - 1) * horizontalSpacing / 2;
|
||||
layer.forEach((node, i) => {
|
||||
chartNodes.push({
|
||||
...node,
|
||||
x: xStart + i * horizontalSpacing,
|
||||
y: y,
|
||||
itemStyle: {
|
||||
color: '#dddddd',
|
||||
borderColor: '#222',
|
||||
borderWidth: 2,
|
||||
},
|
||||
label: {
|
||||
show: true,
|
||||
position: 'inside',
|
||||
color: '#000',
|
||||
fontSize: 12,
|
||||
formatter: node.short_name || node.name,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const chartEdges = rawEdges.map(edge => ({
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
lineStyle: {
|
||||
color: edge.originalColor || '#ccc',
|
||||
width: 2,
|
||||
type: 'solid',
|
||||
},
|
||||
}));
|
||||
|
||||
const option = {
|
||||
backgroundColor: '#fff',
|
||||
tooltip: {},
|
||||
animation: false,
|
||||
series: [{
|
||||
type: 'graph',
|
||||
layout: 'none',
|
||||
coordinateSystem: null,
|
||||
data: chartNodes,
|
||||
links: chartEdges,
|
||||
roam: true,
|
||||
edgeSymbol: ['none', 'arrow'],
|
||||
edgeSymbolSize: [0, 10],
|
||||
lineStyle: {
|
||||
curveness: 0,
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
chart.setOption(option);
|
||||
</script>
|
||||
{% endblock %}
|
||||
1556
meshview/web.py
1556
meshview/web.py
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user