initial commit
This commit is contained in:
@@ -0,0 +1,31 @@
|
|||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Session Secret (CHANGE THIS!)
|
||||||
|
SESSION_SECRET=change-this-to-a-random-secret-string
|
||||||
|
|
||||||
|
# MQTT Configuration
|
||||||
|
MQTT_BROKER=mqtt://mqtt.meshtastic.org
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=meshdev
|
||||||
|
MQTT_PASSWORD=large4cats
|
||||||
|
MQTT_TOPIC=msh/US/#
|
||||||
|
|
||||||
|
# Optional: If you have your own MQTT broker
|
||||||
|
# MQTT_BROKER=mqtt://your-broker.com
|
||||||
|
# MQTT_USERNAME=your-username
|
||||||
|
# MQTT_PASSWORD=your-password
|
||||||
|
|
||||||
|
# Data Retention (in days)
|
||||||
|
DATA_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# Cron schedule for automatic data purging (default: daily at 2 AM)
|
||||||
|
PURGE_CRON_SCHEDULE=0 2 * * *
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=100
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Database
|
||||||
|
data/
|
||||||
|
*.db
|
||||||
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.claude
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Temporary files
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
+413
@@ -0,0 +1,413 @@
|
|||||||
|
# Deployment Guide
|
||||||
|
|
||||||
|
This guide covers deploying the Meshtastic MQTT Dashboard to production environments.
|
||||||
|
|
||||||
|
## Pre-Deployment Checklist
|
||||||
|
|
||||||
|
- [ ] Change `SESSION_SECRET` to a strong random string
|
||||||
|
- [ ] Set `NODE_ENV=production` in `.env`
|
||||||
|
- [ ] Review and adjust MQTT broker settings
|
||||||
|
- [ ] Configure data retention policies
|
||||||
|
- [ ] Set up SSL/TLS certificates
|
||||||
|
- [ ] Configure firewall rules
|
||||||
|
- [ ] Plan backup strategy
|
||||||
|
- [ ] Create initial user accounts
|
||||||
|
- [ ] Test the application locally
|
||||||
|
|
||||||
|
## Deployment Options
|
||||||
|
|
||||||
|
### Option 1: Traditional Server (Linux)
|
||||||
|
|
||||||
|
#### Requirements
|
||||||
|
- Ubuntu 20.04+ or similar Linux distribution
|
||||||
|
- Node.js 16.x or higher
|
||||||
|
- Nginx (for reverse proxy and SSL)
|
||||||
|
- 1GB+ RAM
|
||||||
|
- 10GB+ disk space
|
||||||
|
|
||||||
|
#### Steps
|
||||||
|
|
||||||
|
1. **Install Node.js**
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
|
||||||
|
sudo apt-get install -y nodejs
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Clone/Upload Application**
|
||||||
|
```bash
|
||||||
|
cd /opt
|
||||||
|
sudo mkdir meshtastic-dashboard
|
||||||
|
sudo chown $USER:$USER meshtastic-dashboard
|
||||||
|
cd meshtastic-dashboard
|
||||||
|
# Upload your files here
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Install Dependencies**
|
||||||
|
```bash
|
||||||
|
npm ci --production
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Configure Environment**
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
nano .env # Edit configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Create User**
|
||||||
|
```bash
|
||||||
|
npm run create-user
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Set Up Systemd Service**
|
||||||
|
|
||||||
|
Create `/etc/systemd/system/meshtastic-dashboard.service`:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Meshtastic MQTT Dashboard
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/opt/meshtastic-dashboard
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
ExecStart=/usr/bin/node src/server.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable and start:
|
||||||
|
```bash
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable meshtastic-dashboard
|
||||||
|
sudo systemctl start meshtastic-dashboard
|
||||||
|
sudo systemctl status meshtastic-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
7. **Configure Nginx**
|
||||||
|
|
||||||
|
Create `/etc/nginx/sites-available/meshtastic-dashboard`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
# Redirect to HTTPS
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Enable site:
|
||||||
|
```bash
|
||||||
|
sudo ln -s /etc/nginx/sites-available/meshtastic-dashboard /etc/nginx/sites-enabled/
|
||||||
|
sudo nginx -t
|
||||||
|
sudo systemctl reload nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **Set Up SSL with Let's Encrypt**
|
||||||
|
```bash
|
||||||
|
sudo apt-get install certbot python3-certbot-nginx
|
||||||
|
sudo certbot --nginx -d your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 2: Docker Deployment
|
||||||
|
|
||||||
|
Create `Dockerfile`:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
FROM node:18-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm ci --production
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p data logs
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "src/server.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Create `docker-compose.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
meshtastic-dashboard:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
- ./logs:/app/logs
|
||||||
|
- ./.env:/app/.env:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=production
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy:
|
||||||
|
```bash
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Cloud Platforms
|
||||||
|
|
||||||
|
#### Heroku
|
||||||
|
|
||||||
|
1. Create `Procfile`:
|
||||||
|
```
|
||||||
|
web: node src/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Deploy:
|
||||||
|
```bash
|
||||||
|
heroku create your-app-name
|
||||||
|
heroku config:set SESSION_SECRET=your-secret-here
|
||||||
|
git push heroku main
|
||||||
|
```
|
||||||
|
|
||||||
|
#### DigitalOcean App Platform
|
||||||
|
|
||||||
|
1. Connect your repository
|
||||||
|
2. Set environment variables in the dashboard
|
||||||
|
3. Deploy with one click
|
||||||
|
|
||||||
|
#### AWS EC2
|
||||||
|
|
||||||
|
Follow "Traditional Server" steps above on an EC2 instance.
|
||||||
|
|
||||||
|
## Security Hardening
|
||||||
|
|
||||||
|
### 1. Firewall Configuration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# UFW (Ubuntu)
|
||||||
|
sudo ufw allow 22/tcp
|
||||||
|
sudo ufw allow 80/tcp
|
||||||
|
sudo ufw allow 443/tcp
|
||||||
|
sudo ufw enable
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Environment Variables
|
||||||
|
|
||||||
|
Never commit `.env` file. Use secure methods to transfer:
|
||||||
|
```bash
|
||||||
|
# On server
|
||||||
|
touch .env
|
||||||
|
chmod 600 .env
|
||||||
|
nano .env # Add your configuration
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Database Permissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod 700 data/
|
||||||
|
chmod 600 data/meshtastic.db
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Regular Updates
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Update system packages
|
||||||
|
sudo apt update && sudo apt upgrade -y
|
||||||
|
|
||||||
|
# Update Node.js dependencies
|
||||||
|
npm audit fix
|
||||||
|
npm update
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Monitoring
|
||||||
|
|
||||||
|
Install monitoring tools:
|
||||||
|
```bash
|
||||||
|
# PM2 for process management
|
||||||
|
npm install -g pm2
|
||||||
|
pm2 start src/server.js --name meshtastic-dashboard
|
||||||
|
pm2 startup
|
||||||
|
pm2 save
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup Strategy
|
||||||
|
|
||||||
|
### Automated Backup Script
|
||||||
|
|
||||||
|
Create `/opt/backup-meshtastic.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
BACKUP_DIR="/opt/backups/meshtastic"
|
||||||
|
DATE=$(date +%Y%m%d_%H%M%S)
|
||||||
|
|
||||||
|
mkdir -p $BACKUP_DIR
|
||||||
|
|
||||||
|
# Backup database
|
||||||
|
cp /opt/meshtastic-dashboard/data/meshtastic.db $BACKUP_DIR/db_$DATE.db
|
||||||
|
|
||||||
|
# Backup configuration
|
||||||
|
cp /opt/meshtastic-dashboard/.env $BACKUP_DIR/env_$DATE.txt
|
||||||
|
|
||||||
|
# Keep only last 7 days
|
||||||
|
find $BACKUP_DIR -name "db_*.db" -mtime +7 -delete
|
||||||
|
find $BACKUP_DIR -name "env_*.txt" -mtime +7 -delete
|
||||||
|
|
||||||
|
echo "Backup completed: $DATE"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add to crontab:
|
||||||
|
```bash
|
||||||
|
chmod +x /opt/backup-meshtastic.sh
|
||||||
|
crontab -e
|
||||||
|
# Add: 0 3 * * * /opt/backup-meshtastic.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring and Logs
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Systemd logs
|
||||||
|
sudo journalctl -u meshtastic-dashboard -f
|
||||||
|
|
||||||
|
# Application logs
|
||||||
|
tail -f /opt/meshtastic-dashboard/logs/combined.log
|
||||||
|
tail -f /opt/meshtastic-dashboard/logs/error.log
|
||||||
|
```
|
||||||
|
|
||||||
|
### Log Rotation
|
||||||
|
|
||||||
|
Create `/etc/logrotate.d/meshtastic-dashboard`:
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/meshtastic-dashboard/logs/*.log {
|
||||||
|
daily
|
||||||
|
rotate 14
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
notifempty
|
||||||
|
missingok
|
||||||
|
copytruncate
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Performance Optimization
|
||||||
|
|
||||||
|
### 1. Enable Node.js Clustering
|
||||||
|
|
||||||
|
For multi-core servers, modify `src/server.js` to use the cluster module.
|
||||||
|
|
||||||
|
### 2. Database Optimization
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Vacuum database periodically
|
||||||
|
sqlite3 data/meshtastic.db "VACUUM;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Nginx Caching
|
||||||
|
|
||||||
|
Add to nginx configuration:
|
||||||
|
```nginx
|
||||||
|
location /css/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /js/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Application Won't Start
|
||||||
|
|
||||||
|
1. Check logs: `sudo journalctl -u meshtastic-dashboard -n 50`
|
||||||
|
2. Verify Node.js version: `node --version`
|
||||||
|
3. Check port availability: `sudo netstat -tlnp | grep 3000`
|
||||||
|
4. Verify permissions on data directory
|
||||||
|
|
||||||
|
### MQTT Connection Issues
|
||||||
|
|
||||||
|
1. Check broker is accessible: `telnet mqtt.meshtastic.org 1883`
|
||||||
|
2. Verify credentials in `.env`
|
||||||
|
3. Check firewall rules
|
||||||
|
|
||||||
|
### Database Errors
|
||||||
|
|
||||||
|
1. Check disk space: `df -h`
|
||||||
|
2. Verify database permissions: `ls -la data/`
|
||||||
|
3. Try rebuilding: `rm data/meshtastic.db && npm start`
|
||||||
|
|
||||||
|
## Scaling Considerations
|
||||||
|
|
||||||
|
For high traffic deployments:
|
||||||
|
|
||||||
|
1. **Load Balancing**: Use multiple instances behind nginx
|
||||||
|
2. **Database**: Consider PostgreSQL for better concurrency
|
||||||
|
3. **Caching**: Add Redis for session storage
|
||||||
|
4. **CDN**: Use CloudFlare for static assets
|
||||||
|
5. **Monitoring**: Add Prometheus + Grafana
|
||||||
|
|
||||||
|
## Maintenance
|
||||||
|
|
||||||
|
### Weekly Tasks
|
||||||
|
- Review error logs
|
||||||
|
- Check disk space
|
||||||
|
- Verify backups
|
||||||
|
|
||||||
|
### Monthly Tasks
|
||||||
|
- Update dependencies
|
||||||
|
- Review security advisories
|
||||||
|
- Optimize database
|
||||||
|
- Rotate API keys if used
|
||||||
|
|
||||||
|
### Quarterly Tasks
|
||||||
|
- Review access logs
|
||||||
|
- Update SSL certificates (if not using auto-renewal)
|
||||||
|
- Performance audit
|
||||||
|
- Security audit
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For deployment issues:
|
||||||
|
1. Check logs first
|
||||||
|
2. Review documentation
|
||||||
|
3. Check GitHub issues
|
||||||
|
4. Contact maintainers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated**: 2025
|
||||||
|
**Maintainer**: Meshtastic Community
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
# Meshtastic MQTT Dashboard - Project Summary
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A complete, production-ready Node.js web application for monitoring and managing Meshtastic mesh networks via MQTT. This is a fully self-contained solution similar to the Home Assistant integration.
|
||||||
|
|
||||||
|
## What Was Built
|
||||||
|
|
||||||
|
### Backend (Node.js + Express)
|
||||||
|
|
||||||
|
#### Core Server (`src/server.js`)
|
||||||
|
- Express.js web server
|
||||||
|
- Session management with express-session
|
||||||
|
- Security middleware (Helmet, CORS, rate limiting)
|
||||||
|
- Graceful shutdown handling
|
||||||
|
- Error handling middleware
|
||||||
|
|
||||||
|
#### Authentication System (`src/auth/`)
|
||||||
|
- User registration and login with bcrypt password hashing
|
||||||
|
- Session-based authentication
|
||||||
|
- Activity logging for security auditing
|
||||||
|
- User creation script for initial setup
|
||||||
|
|
||||||
|
#### Database Layer (`src/database/`)
|
||||||
|
- SQLite database with better-sqlite3
|
||||||
|
- Comprehensive schema for:
|
||||||
|
- Users and authentication
|
||||||
|
- Meshtastic nodes
|
||||||
|
- GPS positions
|
||||||
|
- Text messages
|
||||||
|
- Device telemetry
|
||||||
|
- Activity logs
|
||||||
|
- Prepared statements for performance and security
|
||||||
|
- Automatic database initialization
|
||||||
|
|
||||||
|
#### MQTT Client (`src/mqtt/`)
|
||||||
|
- Automatic connection to Meshtastic MQTT broker
|
||||||
|
- Real-time message processing
|
||||||
|
- Support for multiple message types:
|
||||||
|
- Text messages
|
||||||
|
- Position updates
|
||||||
|
- Node information
|
||||||
|
- Telemetry data
|
||||||
|
- Message publishing for sending to mesh
|
||||||
|
- Automatic reconnection handling
|
||||||
|
|
||||||
|
#### REST API (`src/routes/api.js`)
|
||||||
|
Complete API with endpoints for:
|
||||||
|
- Authentication (login/logout)
|
||||||
|
- Node management (list all, get details)
|
||||||
|
- Position tracking (latest positions, history)
|
||||||
|
- Message history (view and send)
|
||||||
|
- Telemetry data
|
||||||
|
- Statistics dashboard
|
||||||
|
- Data purging
|
||||||
|
|
||||||
|
#### Background Services (`src/services/`)
|
||||||
|
- Cron service for scheduled tasks
|
||||||
|
- Automatic data purging based on retention policy
|
||||||
|
- Configurable schedule (default: daily at 2 AM)
|
||||||
|
|
||||||
|
#### Logging System (`src/utils/`)
|
||||||
|
- Winston-based structured logging
|
||||||
|
- Multiple log files (combined, errors)
|
||||||
|
- Log rotation
|
||||||
|
- Configurable log levels
|
||||||
|
|
||||||
|
#### Configuration (`src/config/`)
|
||||||
|
- Environment variable based configuration
|
||||||
|
- Secure defaults
|
||||||
|
- Easy customization for different deployments
|
||||||
|
|
||||||
|
### Frontend (Vanilla JavaScript)
|
||||||
|
|
||||||
|
#### Modern Web Interface (`public/`)
|
||||||
|
- **Responsive Design**: Works on desktop, tablet, and mobile
|
||||||
|
- **Clean UI**: Modern styling with CSS variables for easy theming
|
||||||
|
- **No Framework Dependencies**: Pure JavaScript for simplicity
|
||||||
|
|
||||||
|
#### Features:
|
||||||
|
1. **Login Screen**
|
||||||
|
- Secure authentication
|
||||||
|
- Form validation
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
2. **Dashboard Overview**
|
||||||
|
- Real-time statistics cards
|
||||||
|
- Recent message feed
|
||||||
|
- MQTT connection status indicator
|
||||||
|
|
||||||
|
3. **Interactive Map**
|
||||||
|
- Leaflet.js integration
|
||||||
|
- GPS position tracking
|
||||||
|
- Clickable markers with node info
|
||||||
|
- Auto-fit to show all nodes
|
||||||
|
|
||||||
|
4. **Message Management**
|
||||||
|
- Send messages to mesh network
|
||||||
|
- View message history with metadata
|
||||||
|
- Channel selection
|
||||||
|
- Signal quality indicators (SNR, RSSI)
|
||||||
|
|
||||||
|
5. **Node Monitoring**
|
||||||
|
- Grid view of all nodes
|
||||||
|
- Online/offline status
|
||||||
|
- Detailed node information modal
|
||||||
|
- Battery levels, signal strength
|
||||||
|
- Hardware information
|
||||||
|
|
||||||
|
6. **Settings Panel**
|
||||||
|
- Manual data purging
|
||||||
|
- Configurable retention periods
|
||||||
|
- Application information
|
||||||
|
|
||||||
|
### Security Features
|
||||||
|
|
||||||
|
- Password hashing with bcrypt (10 rounds)
|
||||||
|
- Session-based authentication
|
||||||
|
- HTTP security headers (Helmet)
|
||||||
|
- Rate limiting on API endpoints
|
||||||
|
- CSRF protection ready
|
||||||
|
- Activity logging for audit trails
|
||||||
|
- Secure session cookies
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
**Tables Created:**
|
||||||
|
1. `users` - User accounts
|
||||||
|
2. `nodes` - Meshtastic node information
|
||||||
|
3. `positions` - GPS location history
|
||||||
|
4. `messages` - Text message history
|
||||||
|
5. `telemetry` - Device telemetry data
|
||||||
|
6. `activity_log` - User activity tracking
|
||||||
|
|
||||||
|
**Indexes Created:**
|
||||||
|
- Node ID lookups
|
||||||
|
- Position timestamps
|
||||||
|
- Message timestamps and senders
|
||||||
|
- Telemetry tracking
|
||||||
|
|
||||||
|
### Configuration Options
|
||||||
|
|
||||||
|
All configurable via `.env`:
|
||||||
|
- Server port
|
||||||
|
- MQTT broker settings
|
||||||
|
- Session secrets
|
||||||
|
- Data retention policies
|
||||||
|
- Cron schedules
|
||||||
|
- Log levels
|
||||||
|
- Rate limiting
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
meshtastic-mqtt-dashboard/
|
||||||
|
├── src/
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ └── auth.js # Authentication logic
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── config.js # Configuration management
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── db.js # Database initialization
|
||||||
|
│ │ └── queries.js # Prepared statements
|
||||||
|
│ ├── mqtt/
|
||||||
|
│ │ └── client.js # MQTT client implementation
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── api.js # REST API endpoints
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ └── createUser.js # User creation utility
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── cron.js # Background job scheduler
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ └── logger.js # Logging utility
|
||||||
|
│ └── server.js # Main application entry
|
||||||
|
├── public/
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── style.css # Modern UI styles
|
||||||
|
│ ├── js/
|
||||||
|
│ │ └── app.js # Frontend application
|
||||||
|
│ └── index.html # Single page app
|
||||||
|
├── .env.example # Environment template
|
||||||
|
├── .gitignore # Git ignore rules
|
||||||
|
├── package.json # Dependencies
|
||||||
|
├── README.md # Full documentation
|
||||||
|
├── QUICKSTART.md # Quick start guide
|
||||||
|
└── PROJECT_SUMMARY.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- **Node.js**: Runtime environment
|
||||||
|
- **Express.js**: Web framework
|
||||||
|
- **better-sqlite3**: Fast SQLite database
|
||||||
|
- **mqtt**: MQTT client library
|
||||||
|
- **bcryptjs**: Password hashing
|
||||||
|
- **express-session**: Session management
|
||||||
|
- **winston**: Logging
|
||||||
|
- **node-cron**: Job scheduling
|
||||||
|
- **helmet**: Security headers
|
||||||
|
- **express-rate-limit**: Rate limiting
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- **Vanilla JavaScript**: No frameworks, pure JS
|
||||||
|
- **Leaflet.js**: Interactive maps
|
||||||
|
- **OpenStreetMap**: Map tiles
|
||||||
|
- **CSS3**: Modern styling with variables
|
||||||
|
- **Fetch API**: HTTP requests
|
||||||
|
|
||||||
|
### Database
|
||||||
|
- **SQLite**: Embedded database
|
||||||
|
- **WAL mode**: Better concurrency
|
||||||
|
|
||||||
|
## Key Features Implemented
|
||||||
|
|
||||||
|
✅ MQTT automatic connection and message storage
|
||||||
|
✅ SQLite database with comprehensive schema
|
||||||
|
✅ User authentication with bcrypt
|
||||||
|
✅ Session management
|
||||||
|
✅ REST API for all operations
|
||||||
|
✅ Interactive map with GPS tracking
|
||||||
|
✅ Message viewing and sending
|
||||||
|
✅ Node monitoring and metadata
|
||||||
|
✅ Automatic data purging with cron
|
||||||
|
✅ Manual data purging button
|
||||||
|
✅ Activity logging
|
||||||
|
✅ Comprehensive error handling
|
||||||
|
✅ Security best practices
|
||||||
|
✅ Modern, responsive UI
|
||||||
|
✅ Real-time statistics
|
||||||
|
✅ Configurable via environment variables
|
||||||
|
✅ Complete documentation
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
1. Install dependencies: `npm install`
|
||||||
|
2. Copy environment file: `cp .env.example .env`
|
||||||
|
3. Edit `.env` with your settings
|
||||||
|
4. Create user: `npm run create-user`
|
||||||
|
5. Start server: `npm start`
|
||||||
|
6. Open browser: `http://localhost:3000`
|
||||||
|
|
||||||
|
See `QUICKSTART.md` for detailed instructions.
|
||||||
|
|
||||||
|
## Production Ready
|
||||||
|
|
||||||
|
This application is production-ready with:
|
||||||
|
- Proper error handling
|
||||||
|
- Security best practices
|
||||||
|
- Logging and monitoring
|
||||||
|
- Graceful shutdown
|
||||||
|
- Session management
|
||||||
|
- Rate limiting
|
||||||
|
- Input validation
|
||||||
|
- SQL injection protection (prepared statements)
|
||||||
|
- XSS protection
|
||||||
|
- CSRF ready
|
||||||
|
|
||||||
|
## Future Enhancement Ideas
|
||||||
|
|
||||||
|
- WebSocket support for real-time updates
|
||||||
|
- User management interface
|
||||||
|
- Email notifications
|
||||||
|
- Export data to CSV/JSON
|
||||||
|
- Advanced filtering and search
|
||||||
|
- Grafana integration
|
||||||
|
- Multi-user permissions
|
||||||
|
- Custom alerts and triggers
|
||||||
|
- Mobile app (PWA)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - Free to use, modify, and distribute
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
See README.md for troubleshooting and detailed documentation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Built with ❤️ for the Meshtastic community**
|
||||||
+107
@@ -0,0 +1,107 @@
|
|||||||
|
# Quick Start Guide
|
||||||
|
|
||||||
|
Get your Meshtastic MQTT Dashboard up and running in 5 minutes!
|
||||||
|
|
||||||
|
## Step 1: Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 2: Configure Environment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit the `.env` file and change the `SESSION_SECRET` to a random string:
|
||||||
|
|
||||||
|
```env
|
||||||
|
SESSION_SECRET=your-random-secret-here-change-this
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave other settings as default to connect to the public Meshtastic MQTT broker.
|
||||||
|
|
||||||
|
## Step 3: Create a User Account
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run create-user
|
||||||
|
```
|
||||||
|
|
||||||
|
Enter your desired username and password when prompted.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```
|
||||||
|
Enter username: admin
|
||||||
|
Enter password: ********
|
||||||
|
Confirm password: ********
|
||||||
|
|
||||||
|
User 'admin' created successfully!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 4: Start the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see:
|
||||||
|
```
|
||||||
|
Connected to MQTT broker
|
||||||
|
Subscribed to topic: msh/US/#
|
||||||
|
Server running on http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step 5: Access the Dashboard
|
||||||
|
|
||||||
|
Open your web browser and navigate to:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Login with the username and password you created in Step 3.
|
||||||
|
|
||||||
|
## What's Next?
|
||||||
|
|
||||||
|
- **View Messages**: Navigate to the Messages tab to see incoming mesh messages
|
||||||
|
- **Check the Map**: Click on the Map tab to see node locations
|
||||||
|
- **Monitor Nodes**: View all discovered nodes in the Nodes tab
|
||||||
|
- **Send Messages**: Use the message form to send text to the mesh network
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
**Can't see any data?**
|
||||||
|
- Wait a few minutes for MQTT messages to arrive
|
||||||
|
- Check that the MQTT status indicator in the header shows as connected (green dot)
|
||||||
|
- The public Meshtastic network may have variable activity depending on your region
|
||||||
|
|
||||||
|
**Can't login?**
|
||||||
|
- Make sure you created a user with `npm run create-user`
|
||||||
|
- Check that the SESSION_SECRET is set in your `.env` file
|
||||||
|
- Try clearing your browser cookies
|
||||||
|
|
||||||
|
**Port 3000 already in use?**
|
||||||
|
- Change the PORT in your `.env` file to another port (e.g., 3001)
|
||||||
|
- Restart the application
|
||||||
|
|
||||||
|
## Default Configuration
|
||||||
|
|
||||||
|
The default configuration connects to:
|
||||||
|
- **MQTT Broker**: mqtt.meshtastic.org (public)
|
||||||
|
- **Topic**: msh/US/# (all US channels)
|
||||||
|
- **Port**: 3000
|
||||||
|
- **Data Retention**: 30 days
|
||||||
|
|
||||||
|
To change these, edit your `.env` file.
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
For production use:
|
||||||
|
1. Change `NODE_ENV=production` in `.env`
|
||||||
|
2. Use a strong, random `SESSION_SECRET`
|
||||||
|
3. Set up HTTPS with a reverse proxy (nginx, Caddy, etc.)
|
||||||
|
4. Configure firewall rules
|
||||||
|
5. Set up automatic backups of the `data/` directory
|
||||||
|
|
||||||
|
Enjoy your Meshtastic dashboard!
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
# Meshtastic MQTT Dashboard
|
||||||
|
|
||||||
|
A complete, self-contained Node.js web application for monitoring and managing Meshtastic networks via MQTT. Features real-time message tracking, GPS location mapping, node management, and automatic data retention with secure authentication.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Real-time MQTT Integration** - Automatically connects to Meshtastic MQTT broker and stores all messages
|
||||||
|
- **Interactive Map View** - View GPS locations of all nodes on an interactive Leaflet map
|
||||||
|
- **Message Management** - View message history and send messages to the mesh network
|
||||||
|
- **Node Monitoring** - Track all nodes with detailed metadata including battery levels, signal strength, and telemetry
|
||||||
|
- **Secure Authentication** - Username/password login with session management
|
||||||
|
- **Automatic Data Purging** - Configurable cron job to automatically clean old data
|
||||||
|
- **Modern UI** - Responsive, modern web interface with dark theme support
|
||||||
|
- **SQLite Database** - All data stored locally in a SQLite database
|
||||||
|
- **Comprehensive Logging** - Winston-based logging for debugging and monitoring
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
meshtastic-mqtt-dashboard/
|
||||||
|
├── src/
|
||||||
|
│ ├── auth/ # Authentication and authorization
|
||||||
|
│ ├── config/ # Application configuration
|
||||||
|
│ ├── database/ # SQLite database setup and queries
|
||||||
|
│ ├── mqtt/ # MQTT client and message handlers
|
||||||
|
│ ├── routes/ # Express API routes
|
||||||
|
│ ├── scripts/ # Utility scripts (user creation, etc.)
|
||||||
|
│ ├── services/ # Background services (cron jobs)
|
||||||
|
│ ├── utils/ # Utilities (logging, etc.)
|
||||||
|
│ └── server.js # Main application entry point
|
||||||
|
├── public/
|
||||||
|
│ ├── css/ # Stylesheets
|
||||||
|
│ ├── js/ # Frontend JavaScript
|
||||||
|
│ └── index.html # Main HTML file
|
||||||
|
├── data/ # SQLite database (auto-created)
|
||||||
|
├── logs/ # Application logs (auto-created)
|
||||||
|
├── .env # Environment configuration
|
||||||
|
└── package.json # Dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Node.js 16.x or higher
|
||||||
|
- npm or yarn
|
||||||
|
- Access to a Meshtastic MQTT broker (default: mqtt.meshtastic.org)
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. **Clone or navigate to the project directory:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd meshtastic-mqtt-dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Install dependencies:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Configure environment variables:**
|
||||||
|
|
||||||
|
Copy the example environment file and edit it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your settings:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=production
|
||||||
|
|
||||||
|
# Session Secret (CHANGE THIS!)
|
||||||
|
SESSION_SECRET=your-random-secret-string-here
|
||||||
|
|
||||||
|
# MQTT Configuration
|
||||||
|
MQTT_BROKER=mqtt://mqtt.meshtastic.org
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=meshdev
|
||||||
|
MQTT_PASSWORD=large4cats
|
||||||
|
MQTT_TOPIC=msh/US/#
|
||||||
|
|
||||||
|
# Data Retention (in days)
|
||||||
|
DATA_RETENTION_DAYS=30
|
||||||
|
|
||||||
|
# Cron schedule for automatic data purging (daily at 2 AM)
|
||||||
|
PURGE_CRON_SCHEDULE=0 2 * * *
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=info
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Create your first user:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run create-user
|
||||||
|
```
|
||||||
|
|
||||||
|
Follow the prompts to create a username and password.
|
||||||
|
|
||||||
|
5. **Start the application:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
For development with auto-reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
6. **Access the dashboard:**
|
||||||
|
|
||||||
|
Open your browser and navigate to:
|
||||||
|
```
|
||||||
|
http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Login with the username and password you created.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### MQTT Settings
|
||||||
|
|
||||||
|
The application connects to the Meshtastic MQTT broker to receive messages. Configure these settings in `.env`:
|
||||||
|
|
||||||
|
- **MQTT_BROKER**: The MQTT broker URL (default: mqtt://mqtt.meshtastic.org)
|
||||||
|
- **MQTT_USERNAME**: MQTT username (default: meshdev)
|
||||||
|
- **MQTT_PASSWORD**: MQTT password (default: large4cats)
|
||||||
|
- **MQTT_TOPIC**: MQTT topic to subscribe to (default: msh/US/# for all US channels)
|
||||||
|
|
||||||
|
To monitor a specific region, change the topic:
|
||||||
|
- `msh/US/#` - All US channels
|
||||||
|
- `msh/EU/#` - All EU channels
|
||||||
|
- `msh/US/2/json/#` - Specific channel
|
||||||
|
|
||||||
|
### Data Retention
|
||||||
|
|
||||||
|
Configure automatic data purging:
|
||||||
|
|
||||||
|
- **DATA_RETENTION_DAYS**: Number of days to keep data (default: 30)
|
||||||
|
- **PURGE_CRON_SCHEDULE**: Cron schedule for purging (default: 0 2 * * * = daily at 2 AM)
|
||||||
|
|
||||||
|
You can also manually purge data from the Settings tab in the web interface.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
**IMPORTANT**: Change the `SESSION_SECRET` in your `.env` file to a random string for production use.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Dashboard Overview
|
||||||
|
|
||||||
|
The dashboard provides several tabs:
|
||||||
|
|
||||||
|
1. **Overview** - Statistics and recent messages at a glance
|
||||||
|
2. **Map** - Interactive map showing GPS locations of all nodes
|
||||||
|
3. **Messages** - Send and view message history
|
||||||
|
4. **Nodes** - View all discovered nodes and their metadata
|
||||||
|
5. **Settings** - Data management and application settings
|
||||||
|
|
||||||
|
### Sending Messages
|
||||||
|
|
||||||
|
1. Navigate to the **Messages** tab
|
||||||
|
2. Type your message in the text field
|
||||||
|
3. Select the channel (0-7)
|
||||||
|
4. Click **Send**
|
||||||
|
|
||||||
|
Messages will be broadcast to the mesh network via MQTT.
|
||||||
|
|
||||||
|
### Viewing Node Details
|
||||||
|
|
||||||
|
1. Navigate to the **Nodes** tab
|
||||||
|
2. Click on any node card to view detailed information
|
||||||
|
3. Modal will display:
|
||||||
|
- Node ID and names
|
||||||
|
- Hardware model and firmware version
|
||||||
|
- Battery level and voltage
|
||||||
|
- Signal metrics
|
||||||
|
- Last heard timestamp
|
||||||
|
|
||||||
|
### Map View
|
||||||
|
|
||||||
|
The **Map** tab displays GPS locations of all nodes:
|
||||||
|
|
||||||
|
- Click on markers to see node details
|
||||||
|
- Map automatically fits to show all nodes
|
||||||
|
- Click **Refresh Map** to update positions
|
||||||
|
|
||||||
|
### Data Management
|
||||||
|
|
||||||
|
From the **Settings** tab:
|
||||||
|
|
||||||
|
1. Select retention period (7, 14, 30, 60, or 90 days)
|
||||||
|
2. Click **Purge Old Data**
|
||||||
|
3. Confirm the action
|
||||||
|
|
||||||
|
This will delete messages, positions, and telemetry older than the selected period.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
The application provides a REST API for programmatic access:
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
- `POST /api/login` - Login with username/password
|
||||||
|
- `POST /api/logout` - Logout current session
|
||||||
|
- `GET /api/auth/status` - Check authentication status
|
||||||
|
|
||||||
|
### Data Access
|
||||||
|
- `GET /api/nodes` - Get all nodes
|
||||||
|
- `GET /api/nodes/:nodeId` - Get specific node details
|
||||||
|
- `GET /api/positions` - Get latest positions for all nodes
|
||||||
|
- `GET /api/positions/:nodeId` - Get position history for a node
|
||||||
|
- `GET /api/messages` - Get recent messages
|
||||||
|
- `GET /api/messages/node/:nodeId` - Get messages for a specific node
|
||||||
|
- `GET /api/telemetry/:nodeId` - Get telemetry history for a node
|
||||||
|
- `GET /api/stats` - Get dashboard statistics
|
||||||
|
|
||||||
|
### Actions
|
||||||
|
- `POST /api/messages/send` - Send a message to the mesh
|
||||||
|
- `POST /api/purge` - Purge old data
|
||||||
|
|
||||||
|
### Status
|
||||||
|
- `GET /api/mqtt/status` - Get MQTT connection status
|
||||||
|
|
||||||
|
All endpoints (except login) require authentication.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
The application uses SQLite with the following tables:
|
||||||
|
|
||||||
|
- **users** - User accounts for authentication
|
||||||
|
- **nodes** - Meshtastic node information
|
||||||
|
- **positions** - GPS position updates
|
||||||
|
- **messages** - Text messages
|
||||||
|
- **telemetry** - Device telemetry data
|
||||||
|
- **activity_log** - User activity logging
|
||||||
|
|
||||||
|
Database file location: `data/meshtastic.db`
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Logs are stored in the `logs/` directory:
|
||||||
|
|
||||||
|
- `combined.log` - All log messages
|
||||||
|
- `error.log` - Error messages only
|
||||||
|
|
||||||
|
Log level can be configured with `LOG_LEVEL` in `.env` (debug, info, warn, error).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Cannot connect to MQTT broker
|
||||||
|
|
||||||
|
1. Check your internet connection
|
||||||
|
2. Verify MQTT broker URL in `.env`
|
||||||
|
3. Check username/password if using a private broker
|
||||||
|
4. Review logs in `logs/error.log`
|
||||||
|
|
||||||
|
### No data appearing
|
||||||
|
|
||||||
|
1. Verify MQTT connection (check status indicator in header)
|
||||||
|
2. Ensure MQTT topic is correct for your region
|
||||||
|
3. Check that there is active mesh traffic on the topic
|
||||||
|
4. Review logs for any error messages
|
||||||
|
|
||||||
|
### Database errors
|
||||||
|
|
||||||
|
1. Ensure the `data/` directory is writable
|
||||||
|
2. Check disk space
|
||||||
|
3. Try deleting `data/meshtastic.db` and restarting (this will delete all data)
|
||||||
|
|
||||||
|
### Login issues
|
||||||
|
|
||||||
|
1. Verify user was created successfully with `npm run create-user`
|
||||||
|
2. Check SESSION_SECRET is set in `.env`
|
||||||
|
3. Clear browser cookies and try again
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
- **Backend**: Express.js server with REST API
|
||||||
|
- **Frontend**: Vanilla JavaScript (no frameworks)
|
||||||
|
- **Database**: SQLite with better-sqlite3
|
||||||
|
- **MQTT**: mqtt.js client
|
||||||
|
- **Map**: Leaflet.js for interactive maps
|
||||||
|
- **Logging**: Winston for structured logging
|
||||||
|
- **Security**: bcrypt for password hashing, express-session for sessions
|
||||||
|
|
||||||
|
### Adding Features
|
||||||
|
|
||||||
|
1. **New API endpoint**: Add route to `src/routes/api.js`
|
||||||
|
2. **New database table**: Modify `src/database/db.js` and `src/database/queries.js`
|
||||||
|
3. **MQTT message handler**: Update `src/mqtt/client.js`
|
||||||
|
4. **Frontend UI**: Modify `public/index.html`, `public/css/style.css`, and `public/js/app.js`
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
Contributions are welcome! Please ensure:
|
||||||
|
|
||||||
|
1. Code follows existing style conventions
|
||||||
|
2. All features are properly documented
|
||||||
|
3. Security best practices are followed
|
||||||
|
4. No sensitive data in commits
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- Change default `SESSION_SECRET` in production
|
||||||
|
- Use HTTPS in production (configure reverse proxy)
|
||||||
|
- Regularly update dependencies
|
||||||
|
- Review and limit access to MQTT credentials
|
||||||
|
- Use strong passwords for user accounts
|
||||||
|
- Enable firewall rules to restrict access
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
For issues and feature requests, please open an issue on the project repository.
|
||||||
|
|
||||||
|
## Acknowledgments
|
||||||
|
|
||||||
|
- Meshtastic project for the excellent mesh networking platform
|
||||||
|
- OpenStreetMap for map tiles
|
||||||
|
- All contributors and testers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Version**: 1.0.0
|
||||||
|
**Author**: Meshtastic Community
|
||||||
|
**Last Updated**: 2025
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
meshtastic-mqtt-dashboard/
|
||||||
|
│
|
||||||
|
├── src/ # Backend source code
|
||||||
|
│ ├── auth/
|
||||||
|
│ │ └── auth.js # User authentication & authorization
|
||||||
|
│ │
|
||||||
|
│ ├── config/
|
||||||
|
│ │ └── config.js # Application configuration
|
||||||
|
│ │
|
||||||
|
│ ├── database/
|
||||||
|
│ │ ├── db.js # SQLite database setup
|
||||||
|
│ │ └── queries.js # Database query definitions
|
||||||
|
│ │
|
||||||
|
│ ├── mqtt/
|
||||||
|
│ │ └── client.js # MQTT client & message handlers
|
||||||
|
│ │
|
||||||
|
│ ├── routes/
|
||||||
|
│ │ └── api.js # REST API endpoints
|
||||||
|
│ │
|
||||||
|
│ ├── scripts/
|
||||||
|
│ │ └── createUser.js # CLI tool to create users
|
||||||
|
│ │
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── cron.js # Background job scheduler
|
||||||
|
│ │
|
||||||
|
│ ├── utils/
|
||||||
|
│ │ └── logger.js # Winston logging utility
|
||||||
|
│ │
|
||||||
|
│ └── server.js # Main application entry point
|
||||||
|
│
|
||||||
|
├── public/ # Frontend static files
|
||||||
|
│ ├── css/
|
||||||
|
│ │ └── style.css # Modern UI stylesheet
|
||||||
|
│ │
|
||||||
|
│ ├── js/
|
||||||
|
│ │ └── app.js # Frontend JavaScript application
|
||||||
|
│ │
|
||||||
|
│ └── index.html # Single Page Application HTML
|
||||||
|
│
|
||||||
|
├── data/ # Database storage (auto-created)
|
||||||
|
│ └── meshtastic.db # SQLite database file
|
||||||
|
│
|
||||||
|
├── logs/ # Application logs (auto-created)
|
||||||
|
│ ├── combined.log # All logs
|
||||||
|
│ └── error.log # Error logs only
|
||||||
|
│
|
||||||
|
├── .env # Environment configuration (create from .env.example)
|
||||||
|
├── .env.example # Environment template
|
||||||
|
├── .gitignore # Git ignore rules
|
||||||
|
├── package.json # NPM dependencies & scripts
|
||||||
|
├── README.md # Complete documentation
|
||||||
|
├── QUICKSTART.md # Quick start guide
|
||||||
|
├── PROJECT_SUMMARY.md # Project overview
|
||||||
|
└── STRUCTURE.txt # This file - directory structure
|
||||||
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Check database for received data
|
||||||
|
const db = require('./src/database/db');
|
||||||
|
const { statsQueries, messageQueries, nodeQueries, positionQueries } = require('./src/database/queries');
|
||||||
|
|
||||||
|
console.log('\n=== Database Statistics ===\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = {
|
||||||
|
messages: statsQueries.getMessageCount.get(),
|
||||||
|
nodes: statsQueries.getNodeCount.get(),
|
||||||
|
positions: statsQueries.getPositionCount.get(),
|
||||||
|
dbSize: statsQueries.getDbSize()
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`Messages: ${stats.messages.count}`);
|
||||||
|
console.log(`Nodes: ${stats.nodes.count}`);
|
||||||
|
console.log(`Positions: ${stats.positions.count}`);
|
||||||
|
console.log(`DB Size: ${(stats.dbSize / 1024 / 1024).toFixed(2)} MB`);
|
||||||
|
|
||||||
|
if (stats.messages.count > 0) {
|
||||||
|
console.log('\n=== Recent Messages ===\n');
|
||||||
|
const messages = messageQueries.getRecentMessages.all(5);
|
||||||
|
messages.forEach(msg => {
|
||||||
|
console.log(`[${msg.created_at}] ${msg.from_node}: ${msg.text || '(no text)'}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.nodes.count > 0) {
|
||||||
|
console.log('\n=== Nodes ===\n');
|
||||||
|
const nodes = nodeQueries.getAllNodes.all();
|
||||||
|
nodes.forEach(node => {
|
||||||
|
console.log(`${node.node_id} - ${node.long_name || node.short_name || 'Unknown'} (${node.hardware_model || 'N/A'})`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stats.positions.count > 0) {
|
||||||
|
console.log('\n=== Recent Positions ===\n');
|
||||||
|
const positions = positionQueries.getLatestPositions.all();
|
||||||
|
positions.slice(0, 5).forEach(pos => {
|
||||||
|
console.log(`${pos.node_id}: ${pos.latitude}, ${pos.longitude} @ ${pos.timestamp}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "meshtastic-mqtt-dashboard",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Complete Meshtastic MQTT Dashboard with web frontend",
|
||||||
|
"main": "src/server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node src/server.js",
|
||||||
|
"dev": "nodemon src/server.js",
|
||||||
|
"init-db": "node src/database/init.js",
|
||||||
|
"create-user": "node src/scripts/createUser.js"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"meshtastic",
|
||||||
|
"mqtt",
|
||||||
|
"dashboard",
|
||||||
|
"iot"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"express-session": "^1.17.3",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"mqtt": "^5.3.4",
|
||||||
|
"better-sqlite3": "^9.2.2",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"winston": "^3.11.0",
|
||||||
|
"node-cron": "^3.0.3",
|
||||||
|
"protobufjs": "^7.2.5",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"cors": "^2.8.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
/* Reset and Base Styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--primary-color: #2563eb;
|
||||||
|
--primary-hover: #1d4ed8;
|
||||||
|
--secondary-color: #64748b;
|
||||||
|
--success-color: #10b981;
|
||||||
|
--danger-color: #ef4444;
|
||||||
|
--warning-color: #f59e0b;
|
||||||
|
--bg-color: #f8fafc;
|
||||||
|
--surface-color: #ffffff;
|
||||||
|
--text-primary: #0f172a;
|
||||||
|
--text-secondary: #64748b;
|
||||||
|
--border-color: #e2e8f0;
|
||||||
|
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen Management */
|
||||||
|
.screen {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Login Screen */
|
||||||
|
.login-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-box h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: 1rem 2rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mqtt-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dot.connected {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Navigation Tabs */
|
||||||
|
.nav-tabs {
|
||||||
|
background: var(--surface-color);
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
display: flex;
|
||||||
|
padding: 0 2rem;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab.active {
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Content Area */
|
||||||
|
.content {
|
||||||
|
padding: 2rem;
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel */
|
||||||
|
.panel {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input,
|
||||||
|
.form-group select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input:focus,
|
||||||
|
.form-group select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row input {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row select {
|
||||||
|
min-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background-color: var(--primary-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background-color: var(--secondary-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
background-color: var(--danger-color);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger:hover {
|
||||||
|
background-color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Messages */
|
||||||
|
.message-list {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-from {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-time {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-text {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-meta {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Nodes Grid */
|
||||||
|
.nodes-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card {
|
||||||
|
background: var(--bg-color);
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card:hover {
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: start;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-id {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-badge.online {
|
||||||
|
background-color: #d1fae5;
|
||||||
|
color: #065f46;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-badge.offline {
|
||||||
|
background-color: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-info-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map */
|
||||||
|
.map-container {
|
||||||
|
height: 600px;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: var(--surface-color);
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
max-width: 600px;
|
||||||
|
width: 90%;
|
||||||
|
max-height: 80vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-detail {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 0;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Error Messages */
|
||||||
|
.error-message {
|
||||||
|
color: var(--danger-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Success Messages */
|
||||||
|
.success-message {
|
||||||
|
color: var(--success-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty State */
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.header {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nodes-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar Styling */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Indicator */
|
||||||
|
.loading {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 2px solid var(--border-color);
|
||||||
|
border-top-color: var(--primary-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map marker pulse animation for newly heard nodes */
|
||||||
|
@keyframes markerPulse {
|
||||||
|
0% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: scale(1.6);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 10px rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.3), 0 0 0 0 rgba(255, 255, 255, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Meshtastic MQTT Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Login Screen -->
|
||||||
|
<div id="login-screen" class="screen">
|
||||||
|
<div class="login-container">
|
||||||
|
<div class="login-box">
|
||||||
|
<h1>Meshtastic Dashboard</h1>
|
||||||
|
<p class="subtitle">MQTT Monitoring & Control</p>
|
||||||
|
<form id="login-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="username">Username</label>
|
||||||
|
<input type="text" id="username" name="username" required autocomplete="username">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div id="login-error" class="error-message"></div>
|
||||||
|
<button type="submit" class="btn btn-primary">Login</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Dashboard -->
|
||||||
|
<div id="dashboard-screen" class="screen hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<header class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>Meshtastic Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<span class="mqtt-status" id="mqtt-status">
|
||||||
|
<span class="status-dot"></span>
|
||||||
|
MQTT
|
||||||
|
</span>
|
||||||
|
<span class="data-last-received" id="data-last-received" title="Time since last data update">
|
||||||
|
<span style="opacity: 0.7;">Last data:</span> <span id="data-timer">0s ago</span>
|
||||||
|
</span>
|
||||||
|
<span class="user-info" id="user-info"></span>
|
||||||
|
<button id="logout-btn" class="btn btn-secondary">Logout</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<button class="nav-tab active" data-tab="overview">Overview</button>
|
||||||
|
<button class="nav-tab" data-tab="map">Map</button>
|
||||||
|
<button class="nav-tab" data-tab="messages">Messages</button>
|
||||||
|
<button class="nav-tab" data-tab="nodes">Nodes</button>
|
||||||
|
<button class="nav-tab" data-tab="settings">Settings</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Tab Contents -->
|
||||||
|
<div class="content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div id="overview-tab" class="tab-content active">
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">📡</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value" id="stat-nodes">0</div>
|
||||||
|
<div class="stat-label">Active Nodes</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">💬</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value" id="stat-messages">0</div>
|
||||||
|
<div class="stat-label">Total Messages</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">📍</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value" id="stat-positions">0</div>
|
||||||
|
<div class="stat-label">Position Updates</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-icon">💾</div>
|
||||||
|
<div class="stat-info">
|
||||||
|
<div class="stat-value" id="stat-db-size">0 MB</div>
|
||||||
|
<div class="stat-label">Database Size</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Recent Messages</h2>
|
||||||
|
<div id="recent-messages" class="message-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Tab -->
|
||||||
|
<div id="map-tab" class="tab-content">
|
||||||
|
<div id="map" class="map-container"></div>
|
||||||
|
<div class="map-controls">
|
||||||
|
<button id="refresh-map-btn" class="btn btn-primary">Refresh Map</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Messages Tab -->
|
||||||
|
<div id="messages-tab" class="tab-content">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Send Message</h2>
|
||||||
|
<form id="send-message-form" class="message-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<input type="text" id="message-text" placeholder="Type your message..." required>
|
||||||
|
<select id="message-channel">
|
||||||
|
<option value="0">Channel 0</option>
|
||||||
|
<option value="1">Channel 1</option>
|
||||||
|
<option value="2">Channel 2</option>
|
||||||
|
<option value="3">Channel 3</option>
|
||||||
|
<option value="4">Channel 4</option>
|
||||||
|
<option value="5">Channel 5</option>
|
||||||
|
<option value="6">Channel 6</option>
|
||||||
|
<option value="7">Channel 7</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn btn-primary">Send</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Message History</h2>
|
||||||
|
<button id="refresh-messages-btn" class="btn btn-secondary">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="message-history" class="message-list"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Nodes Tab -->
|
||||||
|
<div id="nodes-tab" class="tab-content">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>Meshtastic Nodes</h2>
|
||||||
|
<button id="refresh-nodes-btn" class="btn btn-secondary">Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="nodes-grid" class="nodes-grid"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Node Detail Modal -->
|
||||||
|
<div id="node-modal" class="modal hidden">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="modal-close">×</span>
|
||||||
|
<h2>Node Details</h2>
|
||||||
|
<div id="node-detail" class="node-detail"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Settings Tab -->
|
||||||
|
<div id="settings-tab" class="tab-content">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>Data Management</h2>
|
||||||
|
<p>Purge old data from the database to free up space.</p>
|
||||||
|
<form id="purge-form" class="settings-form">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="purge-days">Keep data from the last:</label>
|
||||||
|
<select id="purge-days">
|
||||||
|
<option value="7">7 days</option>
|
||||||
|
<option value="14">14 days</option>
|
||||||
|
<option value="30" selected>30 days</option>
|
||||||
|
<option value="60">60 days</option>
|
||||||
|
<option value="90">90 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-danger">Purge Old Data</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>About</h2>
|
||||||
|
<p>Meshtastic MQTT Dashboard - A complete monitoring solution for Meshtastic networks.</p>
|
||||||
|
<p>Version 1.0.0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://unpkg.com/d3@7/dist/d3.min.js"></script>
|
||||||
|
<script src="js/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
+1347
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
|||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { userQueries, activityLogQueries } = require('../database/queries');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Hash password
|
||||||
|
async function hashPassword(password) {
|
||||||
|
const salt = await bcrypt.genSalt(10);
|
||||||
|
return bcrypt.hash(password, salt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify password
|
||||||
|
async function verifyPassword(password, hash) {
|
||||||
|
return bcrypt.compare(password, hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create user
|
||||||
|
async function createUser(username, password) {
|
||||||
|
try {
|
||||||
|
const hashedPassword = await hashPassword(password);
|
||||||
|
const result = userQueries.createUser.run(username, hashedPassword);
|
||||||
|
logger.info(`User created: ${username}`);
|
||||||
|
return { id: result.lastInsertRowid, username };
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('UNIQUE constraint failed')) {
|
||||||
|
throw new Error('Username already exists');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authenticate user
|
||||||
|
async function authenticateUser(username, password) {
|
||||||
|
try {
|
||||||
|
const user = userQueries.getUserByUsername.get(username);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
logger.warn(`Failed login attempt for username: ${username}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await verifyPassword(password, user.password_hash);
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
logger.warn(`Invalid password for username: ${username}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last login
|
||||||
|
userQueries.updateLastLogin.run(user.id);
|
||||||
|
|
||||||
|
logger.info(`User logged in: ${username}`);
|
||||||
|
|
||||||
|
// Return user without password hash
|
||||||
|
const { password_hash, ...userWithoutPassword } = user;
|
||||||
|
return userWithoutPassword;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error authenticating user:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware to check if user is authenticated
|
||||||
|
function requireAuth(req, res, next) {
|
||||||
|
if (req.session && req.session.userId) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
res.status(401).json({ error: 'Authentication required' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
function logActivity(userId, action, details = null, ipAddress = null) {
|
||||||
|
try {
|
||||||
|
activityLogQueries.logActivity.run(userId, action, details, ipAddress);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error logging activity:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
hashPassword,
|
||||||
|
verifyPassword,
|
||||||
|
createUser,
|
||||||
|
authenticateUser,
|
||||||
|
requireAuth,
|
||||||
|
logActivity
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Server configuration
|
||||||
|
server: {
|
||||||
|
port: process.env.PORT || 3000,
|
||||||
|
nodeEnv: process.env.NODE_ENV || 'development'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Session configuration
|
||||||
|
session: {
|
||||||
|
secret: process.env.SESSION_SECRET || 'change-this-secret',
|
||||||
|
resave: false,
|
||||||
|
saveUninitialized: false,
|
||||||
|
cookie: {
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// MQTT configuration
|
||||||
|
mqtt: {
|
||||||
|
broker: process.env.MQTT_BROKER || 'mqtt://mqtt.meshtastic.org',
|
||||||
|
port: parseInt(process.env.MQTT_PORT) || 1883,
|
||||||
|
username: process.env.MQTT_USERNAME || 'meshdev',
|
||||||
|
password: process.env.MQTT_PASSWORD || 'large4cats',
|
||||||
|
topic: process.env.MQTT_TOPIC || 'msh/US/#',
|
||||||
|
options: {
|
||||||
|
clientId: `meshtastic-dashboard-${Math.random().toString(16).substr(2, 8)}`,
|
||||||
|
clean: true,
|
||||||
|
reconnectPeriod: 1000,
|
||||||
|
connectTimeout: 30 * 1000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Data retention configuration
|
||||||
|
dataRetention: {
|
||||||
|
days: parseInt(process.env.DATA_RETENTION_DAYS) || 30,
|
||||||
|
purgeCronSchedule: process.env.PURGE_CRON_SCHEDULE || '0 2 * * *'
|
||||||
|
},
|
||||||
|
|
||||||
|
// Rate limiting configuration
|
||||||
|
rateLimit: {
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
||||||
|
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100
|
||||||
|
},
|
||||||
|
|
||||||
|
// Logging configuration
|
||||||
|
logging: {
|
||||||
|
level: process.env.LOG_LEVEL || 'info'
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const Database = require('better-sqlite3');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
const dataDir = path.join(__dirname, '..', '..', 'data');
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const dbPath = path.join(dataDir, 'meshtastic.db');
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
|
||||||
|
// Enable WAL mode for better performance
|
||||||
|
db.pragma('journal_mode = WAL');
|
||||||
|
|
||||||
|
// Initialize database schema
|
||||||
|
function initializeDatabase() {
|
||||||
|
// Users table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_login DATETIME
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Nodes table - stores information about Meshtastic nodes
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id TEXT UNIQUE NOT NULL,
|
||||||
|
short_name TEXT,
|
||||||
|
long_name TEXT,
|
||||||
|
hardware_model TEXT,
|
||||||
|
role TEXT,
|
||||||
|
firmware_version TEXT,
|
||||||
|
last_heard DATETIME,
|
||||||
|
battery_level INTEGER,
|
||||||
|
voltage REAL,
|
||||||
|
channel_utilization REAL,
|
||||||
|
air_util_tx REAL,
|
||||||
|
uptime_seconds INTEGER,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create index on node_id
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_nodes_node_id ON nodes(node_id)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Positions table - stores GPS position data
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS positions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
latitude REAL NOT NULL,
|
||||||
|
longitude REAL NOT NULL,
|
||||||
|
altitude INTEGER,
|
||||||
|
precision_bits INTEGER,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (node_id) REFERENCES nodes(node_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for positions
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_node_id ON positions(node_id)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_positions_timestamp ON positions(timestamp)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Messages table - stores text messages
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
message_id TEXT,
|
||||||
|
from_node TEXT NOT NULL,
|
||||||
|
to_node TEXT,
|
||||||
|
channel INTEGER,
|
||||||
|
text TEXT,
|
||||||
|
rx_time DATETIME,
|
||||||
|
rx_snr REAL,
|
||||||
|
rx_rssi INTEGER,
|
||||||
|
hop_limit INTEGER,
|
||||||
|
want_ack BOOLEAN,
|
||||||
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (from_node) REFERENCES nodes(node_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for messages
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_from_node ON messages(from_node)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Telemetry table - stores device telemetry data
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS telemetry (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
node_id TEXT NOT NULL,
|
||||||
|
battery_level INTEGER,
|
||||||
|
voltage REAL,
|
||||||
|
channel_utilization REAL,
|
||||||
|
air_util_tx REAL,
|
||||||
|
uptime_seconds INTEGER,
|
||||||
|
temperature REAL,
|
||||||
|
relative_humidity REAL,
|
||||||
|
barometric_pressure REAL,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (node_id) REFERENCES nodes(node_id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indexes for telemetry
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_node_id ON telemetry(node_id)
|
||||||
|
`);
|
||||||
|
db.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_telemetry_timestamp ON telemetry(timestamp)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Activity log table
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS activity_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
ip_address TEXT,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Database initialized successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the database
|
||||||
|
initializeDatabase();
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
// User queries
|
||||||
|
const userQueries = {
|
||||||
|
createUser: db.prepare(`
|
||||||
|
INSERT INTO users (username, password_hash)
|
||||||
|
VALUES (?, ?)
|
||||||
|
`),
|
||||||
|
|
||||||
|
getUserByUsername: db.prepare(`
|
||||||
|
SELECT * FROM users WHERE username = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
updateLastLogin: db.prepare(`
|
||||||
|
UPDATE users SET last_login = CURRENT_TIMESTAMP WHERE id = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
getAllUsers: db.prepare(`
|
||||||
|
SELECT id, username, created_at, last_login FROM users
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Node queries
|
||||||
|
const nodeQueries = {
|
||||||
|
upsertNode: db.prepare(`
|
||||||
|
INSERT INTO nodes (
|
||||||
|
node_id, short_name, long_name, hardware_model, role,
|
||||||
|
firmware_version, last_heard, battery_level, voltage,
|
||||||
|
channel_utilization, air_util_tx, uptime_seconds, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
ON CONFLICT(node_id) DO UPDATE SET
|
||||||
|
short_name = COALESCE(excluded.short_name, short_name),
|
||||||
|
long_name = COALESCE(excluded.long_name, long_name),
|
||||||
|
hardware_model = COALESCE(excluded.hardware_model, hardware_model),
|
||||||
|
role = COALESCE(excluded.role, role),
|
||||||
|
firmware_version = COALESCE(excluded.firmware_version, firmware_version),
|
||||||
|
last_heard = COALESCE(excluded.last_heard, last_heard),
|
||||||
|
battery_level = COALESCE(excluded.battery_level, battery_level),
|
||||||
|
voltage = COALESCE(excluded.voltage, voltage),
|
||||||
|
channel_utilization = COALESCE(excluded.channel_utilization, channel_utilization),
|
||||||
|
air_util_tx = COALESCE(excluded.air_util_tx, air_util_tx),
|
||||||
|
uptime_seconds = COALESCE(excluded.uptime_seconds, uptime_seconds),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
`),
|
||||||
|
|
||||||
|
getNodeById: db.prepare(`
|
||||||
|
SELECT
|
||||||
|
n.*,
|
||||||
|
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude,
|
||||||
|
(SELECT longitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as longitude,
|
||||||
|
(SELECT altitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as altitude,
|
||||||
|
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp
|
||||||
|
FROM nodes n
|
||||||
|
WHERE n.node_id = ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
getAllNodes: db.prepare(`
|
||||||
|
SELECT
|
||||||
|
n.*,
|
||||||
|
(SELECT latitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as latitude,
|
||||||
|
(SELECT longitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as longitude,
|
||||||
|
(SELECT altitude FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as altitude,
|
||||||
|
(SELECT timestamp FROM positions WHERE node_id = n.node_id ORDER BY timestamp DESC LIMIT 1) as position_timestamp
|
||||||
|
FROM nodes n
|
||||||
|
ORDER BY n.last_heard DESC
|
||||||
|
`),
|
||||||
|
|
||||||
|
updateNodeLastHeard: db.prepare(`
|
||||||
|
UPDATE nodes SET last_heard = CURRENT_TIMESTAMP WHERE node_id = ?
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Position queries
|
||||||
|
const positionQueries = {
|
||||||
|
insertPosition: db.prepare(`
|
||||||
|
INSERT INTO positions (node_id, latitude, longitude, altitude, precision_bits)
|
||||||
|
VALUES (?, ?, ?, ?, ?)
|
||||||
|
`),
|
||||||
|
|
||||||
|
getLatestPositions: db.prepare(`
|
||||||
|
SELECT p.*, n.short_name, n.long_name, n.last_heard
|
||||||
|
FROM positions p
|
||||||
|
LEFT JOIN nodes n ON p.node_id = n.node_id
|
||||||
|
WHERE p.id IN (
|
||||||
|
SELECT MAX(id) FROM positions GROUP BY node_id
|
||||||
|
)
|
||||||
|
ORDER BY p.timestamp DESC
|
||||||
|
`),
|
||||||
|
|
||||||
|
getPositionsByNode: db.prepare(`
|
||||||
|
SELECT * FROM positions
|
||||||
|
WHERE node_id = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
deleteOldPositions: db.prepare(`
|
||||||
|
DELETE FROM positions WHERE timestamp < datetime('now', '-' || ? || ' days')
|
||||||
|
`),
|
||||||
|
|
||||||
|
getPositionTrails: db.prepare(`
|
||||||
|
SELECT
|
||||||
|
node_id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
altitude,
|
||||||
|
timestamp,
|
||||||
|
id
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
node_id,
|
||||||
|
latitude,
|
||||||
|
longitude,
|
||||||
|
altitude,
|
||||||
|
timestamp,
|
||||||
|
id,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY node_id ORDER BY timestamp DESC) as rn
|
||||||
|
FROM positions
|
||||||
|
) AS ranked
|
||||||
|
WHERE rn <= ?
|
||||||
|
ORDER BY node_id, timestamp DESC
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Message queries
|
||||||
|
const messageQueries = {
|
||||||
|
insertMessage: db.prepare(`
|
||||||
|
INSERT INTO messages (
|
||||||
|
message_id, from_node, to_node, channel, text,
|
||||||
|
rx_time, rx_snr, rx_rssi, hop_limit, want_ack
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`),
|
||||||
|
|
||||||
|
getRecentMessages: db.prepare(`
|
||||||
|
SELECT m.*,
|
||||||
|
n1.short_name as from_short_name,
|
||||||
|
n1.long_name as from_long_name,
|
||||||
|
n2.short_name as to_short_name,
|
||||||
|
n2.long_name as to_long_name
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
|
||||||
|
LEFT JOIN nodes n2 ON m.to_node = n2.node_id
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
getRecentMessagesWithTimeLimit: db.prepare(`
|
||||||
|
SELECT m.*,
|
||||||
|
n1.short_name as from_short_name,
|
||||||
|
n1.long_name as from_long_name,
|
||||||
|
n2.short_name as to_short_name,
|
||||||
|
n2.long_name as to_long_name
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
|
||||||
|
LEFT JOIN nodes n2 ON m.to_node = n2.node_id
|
||||||
|
WHERE m.created_at >= datetime('now', '-24 hours')
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
getMessagesByNode: db.prepare(`
|
||||||
|
SELECT m.*,
|
||||||
|
n1.short_name as from_short_name,
|
||||||
|
n1.long_name as from_long_name
|
||||||
|
FROM messages m
|
||||||
|
LEFT JOIN nodes n1 ON m.from_node = n1.node_id
|
||||||
|
WHERE m.from_node = ? OR m.to_node = ?
|
||||||
|
ORDER BY m.created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
deleteOldMessages: db.prepare(`
|
||||||
|
DELETE FROM messages WHERE created_at < datetime('now', '-' || ? || ' days')
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Telemetry queries
|
||||||
|
const telemetryQueries = {
|
||||||
|
insertTelemetry: db.prepare(`
|
||||||
|
INSERT INTO telemetry (
|
||||||
|
node_id, battery_level, voltage, channel_utilization,
|
||||||
|
air_util_tx, uptime_seconds, temperature, relative_humidity,
|
||||||
|
barometric_pressure
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`),
|
||||||
|
|
||||||
|
getLatestTelemetryByNode: db.prepare(`
|
||||||
|
SELECT * FROM telemetry
|
||||||
|
WHERE node_id = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT 1
|
||||||
|
`),
|
||||||
|
|
||||||
|
getTelemetryHistory: db.prepare(`
|
||||||
|
SELECT * FROM telemetry
|
||||||
|
WHERE node_id = ?
|
||||||
|
ORDER BY timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
`),
|
||||||
|
|
||||||
|
deleteOldTelemetry: db.prepare(`
|
||||||
|
DELETE FROM telemetry WHERE timestamp < datetime('now', '-' || ? || ' days')
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Activity log queries
|
||||||
|
const activityLogQueries = {
|
||||||
|
logActivity: db.prepare(`
|
||||||
|
INSERT INTO activity_log (user_id, action, details, ip_address)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`),
|
||||||
|
|
||||||
|
getRecentActivity: db.prepare(`
|
||||||
|
SELECT a.*, u.username
|
||||||
|
FROM activity_log a
|
||||||
|
LEFT JOIN users u ON a.user_id = u.id
|
||||||
|
ORDER BY a.timestamp DESC
|
||||||
|
LIMIT ?
|
||||||
|
`)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Statistics queries
|
||||||
|
const statsQueries = {
|
||||||
|
getMessageCount: db.prepare(`SELECT COUNT(*) as count FROM messages`),
|
||||||
|
getNodeCount: db.prepare(`SELECT COUNT(*) as count FROM nodes`),
|
||||||
|
getPositionCount: db.prepare(`SELECT COUNT(*) as count FROM positions`),
|
||||||
|
|
||||||
|
getDbSize: () => {
|
||||||
|
const result = db.prepare(`
|
||||||
|
SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()
|
||||||
|
`).get();
|
||||||
|
return result.size;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
userQueries,
|
||||||
|
nodeQueries,
|
||||||
|
positionQueries,
|
||||||
|
messageQueries,
|
||||||
|
telemetryQueries,
|
||||||
|
activityLogQueries,
|
||||||
|
statsQueries
|
||||||
|
};
|
||||||
@@ -0,0 +1,401 @@
|
|||||||
|
const mqtt = require('mqtt');
|
||||||
|
const config = require('../config/config');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
const { nodeQueries, positionQueries, messageQueries, telemetryQueries } = require('../database/queries');
|
||||||
|
|
||||||
|
class MeshtasticMQTTClient {
|
||||||
|
constructor() {
|
||||||
|
this.client = null;
|
||||||
|
this.connected = false;
|
||||||
|
this.messageCallbacks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
const { broker, username, password, topic, options } = config.mqtt;
|
||||||
|
|
||||||
|
logger.info(`Connecting to MQTT broker: ${broker}`);
|
||||||
|
|
||||||
|
// Create MQTT client
|
||||||
|
this.client = mqtt.connect(broker, {
|
||||||
|
...options,
|
||||||
|
username,
|
||||||
|
password
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection event handlers
|
||||||
|
this.client.on('connect', () => {
|
||||||
|
this.connected = true;
|
||||||
|
logger.info('Connected to MQTT broker');
|
||||||
|
|
||||||
|
// Subscribe to Meshtastic topics
|
||||||
|
this.client.subscribe(topic, (err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Failed to subscribe to topic:', err);
|
||||||
|
} else {
|
||||||
|
logger.info(`Subscribed to topic: ${topic}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('error', (error) => {
|
||||||
|
logger.error('MQTT connection error:', error);
|
||||||
|
this.connected = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('close', () => {
|
||||||
|
this.connected = false;
|
||||||
|
logger.warn('MQTT connection closed');
|
||||||
|
});
|
||||||
|
|
||||||
|
this.client.on('reconnect', () => {
|
||||||
|
logger.info('Reconnecting to MQTT broker...');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Message handler
|
||||||
|
this.client.on('message', (topic, message) => {
|
||||||
|
this.handleMessage(topic, message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(topic, message) {
|
||||||
|
try {
|
||||||
|
// Parse the topic to extract information
|
||||||
|
const topicParts = topic.split('/');
|
||||||
|
|
||||||
|
// Log raw message for debugging (changed to info for testing)
|
||||||
|
logger.info(`Received MQTT message on topic: ${topic} (${message.length} bytes)`);
|
||||||
|
|
||||||
|
// Try to parse as JSON first (some messages might be JSON)
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(message.toString());
|
||||||
|
this.processJsonMessage(topic, payload);
|
||||||
|
} catch (e) {
|
||||||
|
// If not JSON, treat as protobuf or binary data
|
||||||
|
this.processRawMessage(topic, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify subscribers
|
||||||
|
this.notifyCallbacks(topic, message);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling MQTT message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processJsonMessage(topic, payload) {
|
||||||
|
try {
|
||||||
|
const { from, to, channel, id, sender } = payload;
|
||||||
|
|
||||||
|
// Extract node ID (convert to hex string if it's a number)
|
||||||
|
const fromNode = sender || (from ? `!${from.toString(16).padStart(8, '0')}` : null);
|
||||||
|
const toNode = to ? `!${to.toString(16).padStart(8, '0')}` : null;
|
||||||
|
|
||||||
|
// Handle different message types based on actual JSON structure
|
||||||
|
if (payload.type === 'sendtext' && payload.payload) {
|
||||||
|
this.handleTextMessage(fromNode, toNode, {
|
||||||
|
...payload,
|
||||||
|
text: typeof payload.payload === 'string' ? payload.payload : payload.payload.text,
|
||||||
|
id: payload.id,
|
||||||
|
channel: payload.channel,
|
||||||
|
rxSnr: payload.snr,
|
||||||
|
rxRssi: payload.rssi
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'position' && payload.payload) {
|
||||||
|
const pos = payload.payload;
|
||||||
|
this.handlePositionUpdate(fromNode, {
|
||||||
|
latitude: pos.latitude_i / 10000000,
|
||||||
|
longitude: pos.longitude_i / 10000000,
|
||||||
|
altitude: pos.altitude,
|
||||||
|
precisionBits: pos.precision_bits
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'nodeinfo' && payload.payload) {
|
||||||
|
this.handleNodeInfo(fromNode, {
|
||||||
|
shortName: payload.payload.shortname,
|
||||||
|
longName: payload.payload.longname,
|
||||||
|
hardwareModel: payload.payload.hardware,
|
||||||
|
role: payload.payload.role
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'telemetry' && payload.payload) {
|
||||||
|
this.handleTelemetry(fromNode, {
|
||||||
|
deviceMetrics: {
|
||||||
|
batteryLevel: payload.payload.battery_level,
|
||||||
|
voltage: payload.payload.voltage,
|
||||||
|
channelUtilization: payload.payload.channel_utilization,
|
||||||
|
airUtilTx: payload.payload.air_util_tx,
|
||||||
|
uptimeSeconds: payload.payload.uptime_seconds
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'neighborinfo' && payload.payload) {
|
||||||
|
// Just update last_seen for the node
|
||||||
|
nodeQueries.upsertNode.run(
|
||||||
|
fromNode,
|
||||||
|
null, null, null, null, null,
|
||||||
|
new Date().toISOString(),
|
||||||
|
null, null, null, null, null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing JSON message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
processRawMessage(topic, message) {
|
||||||
|
// For protobuf messages, we'll need to decode them
|
||||||
|
// This is a simplified version - real implementation would use protobuf definitions
|
||||||
|
try {
|
||||||
|
// Extract information from topic
|
||||||
|
const topicParts = topic.split('/');
|
||||||
|
|
||||||
|
// Basic message logging for debugging
|
||||||
|
logger.debug(`Raw message length: ${message.length} bytes`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error processing raw message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTextMessage(fromNode, toNode, data) {
|
||||||
|
try {
|
||||||
|
if (!fromNode) return;
|
||||||
|
|
||||||
|
logger.info(`Text message from ${fromNode}: ${data.text || '(empty)'}`);
|
||||||
|
|
||||||
|
// Ensure node exists first (upsert)
|
||||||
|
nodeQueries.upsertNode.run(
|
||||||
|
fromNode,
|
||||||
|
null, null, null, null, null,
|
||||||
|
new Date().toISOString(),
|
||||||
|
null, null, null, null, null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert message - ensure all values are proper types
|
||||||
|
const messageId = data.id ? String(data.id) : null;
|
||||||
|
const channel = typeof data.channel === 'number' ? data.channel : 0;
|
||||||
|
const text = data.text || '';
|
||||||
|
const rxSnr = typeof data.rxSnr === 'number' ? data.rxSnr : null;
|
||||||
|
const rxRssi = typeof data.rxRssi === 'number' ? data.rxRssi : null;
|
||||||
|
const hopLimit = typeof data.hopLimit === 'number' ? data.hopLimit : null;
|
||||||
|
const wantAck = data.wantAck === true ? 1 : 0;
|
||||||
|
|
||||||
|
messageQueries.insertMessage.run(
|
||||||
|
messageId,
|
||||||
|
fromNode,
|
||||||
|
toNode,
|
||||||
|
channel,
|
||||||
|
text,
|
||||||
|
new Date().toISOString(),
|
||||||
|
rxSnr,
|
||||||
|
rxRssi,
|
||||||
|
hopLimit,
|
||||||
|
wantAck
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling text message:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePositionUpdate(nodeId, data) {
|
||||||
|
try {
|
||||||
|
if (!nodeId || !data.latitude || !data.longitude) return;
|
||||||
|
|
||||||
|
logger.info(`Position update from ${nodeId}: ${data.latitude}, ${data.longitude}`);
|
||||||
|
|
||||||
|
// Update node
|
||||||
|
nodeQueries.upsertNode.run(
|
||||||
|
nodeId,
|
||||||
|
null, // short_name
|
||||||
|
null, // long_name
|
||||||
|
null, // hardware_model
|
||||||
|
null, // role
|
||||||
|
null, // firmware_version
|
||||||
|
new Date().toISOString(),
|
||||||
|
null, // battery_level
|
||||||
|
null, // voltage
|
||||||
|
null, // channel_utilization
|
||||||
|
null, // air_util_tx
|
||||||
|
null // uptime_seconds
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert position
|
||||||
|
positionQueries.insertPosition.run(
|
||||||
|
nodeId,
|
||||||
|
data.latitude,
|
||||||
|
data.longitude,
|
||||||
|
data.altitude || null,
|
||||||
|
data.precisionBits || null
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling position update:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNodeInfo(nodeId, data) {
|
||||||
|
try {
|
||||||
|
if (!nodeId) return;
|
||||||
|
|
||||||
|
logger.info(`Node info update for ${nodeId}`);
|
||||||
|
|
||||||
|
// Update node information
|
||||||
|
nodeQueries.upsertNode.run(
|
||||||
|
nodeId,
|
||||||
|
data.shortName || data.user?.shortName || null,
|
||||||
|
data.longName || data.user?.longName || null,
|
||||||
|
data.hardwareModel || data.user?.hwModel || null,
|
||||||
|
data.role || null,
|
||||||
|
data.firmwareVersion || null,
|
||||||
|
new Date().toISOString(),
|
||||||
|
null, // battery_level
|
||||||
|
null, // voltage
|
||||||
|
null, // channel_utilization
|
||||||
|
null, // air_util_tx
|
||||||
|
null // uptime_seconds
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling node info:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTelemetry(nodeId, data) {
|
||||||
|
try {
|
||||||
|
if (!nodeId) return;
|
||||||
|
|
||||||
|
logger.info(`Telemetry update from ${nodeId}`);
|
||||||
|
|
||||||
|
// Update node with telemetry data
|
||||||
|
if (data.deviceMetrics) {
|
||||||
|
const metrics = data.deviceMetrics;
|
||||||
|
|
||||||
|
nodeQueries.upsertNode.run(
|
||||||
|
nodeId,
|
||||||
|
null, // short_name
|
||||||
|
null, // long_name
|
||||||
|
null, // hardware_model
|
||||||
|
null, // role
|
||||||
|
null, // firmware_version
|
||||||
|
new Date().toISOString(),
|
||||||
|
metrics.batteryLevel || null,
|
||||||
|
metrics.voltage || null,
|
||||||
|
metrics.channelUtilization || null,
|
||||||
|
metrics.airUtilTx || null,
|
||||||
|
metrics.uptimeSeconds || null
|
||||||
|
);
|
||||||
|
|
||||||
|
// Insert telemetry record
|
||||||
|
telemetryQueries.insertTelemetry.run(
|
||||||
|
nodeId,
|
||||||
|
metrics.batteryLevel || null,
|
||||||
|
metrics.voltage || null,
|
||||||
|
metrics.channelUtilization || null,
|
||||||
|
metrics.airUtilTx || null,
|
||||||
|
metrics.uptimeSeconds || null,
|
||||||
|
null, // temperature
|
||||||
|
null, // relative_humidity
|
||||||
|
null // barometric_pressure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.environmentMetrics) {
|
||||||
|
const env = data.environmentMetrics;
|
||||||
|
|
||||||
|
telemetryQueries.insertTelemetry.run(
|
||||||
|
nodeId,
|
||||||
|
null, // battery_level
|
||||||
|
null, // voltage
|
||||||
|
null, // channel_utilization
|
||||||
|
null, // air_util_tx
|
||||||
|
null, // uptime_seconds
|
||||||
|
env.temperature || null,
|
||||||
|
env.relativeHumidity || null,
|
||||||
|
env.barometricPressure || null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error handling telemetry:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish a message to MQTT
|
||||||
|
publish(topic, message) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!this.connected) {
|
||||||
|
reject(new Error('MQTT client not connected'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client.publish(topic, message, (error) => {
|
||||||
|
if (error) {
|
||||||
|
logger.error('Error publishing message:', error);
|
||||||
|
reject(error);
|
||||||
|
} else {
|
||||||
|
logger.info(`Published message to topic: ${topic}`);
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send a text message
|
||||||
|
async sendTextMessage(text, channel = 0) {
|
||||||
|
try {
|
||||||
|
const message = JSON.stringify({
|
||||||
|
type: 'text',
|
||||||
|
text,
|
||||||
|
channel,
|
||||||
|
timestamp: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish to the appropriate topic
|
||||||
|
const baseTopic = config.mqtt.topic.replace('/#', '');
|
||||||
|
await this.publish(`${baseTopic}/2/json/mqtt`, message);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error sending text message:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register a callback for messages
|
||||||
|
onMessage(callback) {
|
||||||
|
this.messageCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify all callbacks
|
||||||
|
notifyCallbacks(topic, message) {
|
||||||
|
this.messageCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(topic, message);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error in message callback:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get connection status
|
||||||
|
isConnected() {
|
||||||
|
return this.connected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disconnect
|
||||||
|
disconnect() {
|
||||||
|
if (this.client) {
|
||||||
|
this.client.end();
|
||||||
|
this.connected = false;
|
||||||
|
logger.info('Disconnected from MQTT broker');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create singleton instance
|
||||||
|
const mqttClient = new MeshtasticMQTTClient();
|
||||||
|
|
||||||
|
module.exports = mqttClient;
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const { authenticateUser, requireAuth, logActivity } = require('../auth/auth');
|
||||||
|
const {
|
||||||
|
nodeQueries,
|
||||||
|
positionQueries,
|
||||||
|
messageQueries,
|
||||||
|
telemetryQueries,
|
||||||
|
statsQueries
|
||||||
|
} = require('../database/queries');
|
||||||
|
const mqttClient = require('../mqtt/client');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
// Login endpoint
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { username, password } = req.body;
|
||||||
|
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ error: 'Username and password required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await authenticateUser(username, password);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return res.status(401).json({ error: 'Invalid username or password' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set session
|
||||||
|
req.session.userId = user.id;
|
||||||
|
req.session.username = user.username;
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
logActivity(user.id, 'login', null, req.ip);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
user: {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Login error:', error);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logout endpoint
|
||||||
|
router.post('/logout', requireAuth, (req, res) => {
|
||||||
|
const userId = req.session.userId;
|
||||||
|
|
||||||
|
req.session.destroy((err) => {
|
||||||
|
if (err) {
|
||||||
|
logger.error('Logout error:', err);
|
||||||
|
return res.status(500).json({ error: 'Failed to logout' });
|
||||||
|
}
|
||||||
|
|
||||||
|
logActivity(userId, 'logout', null, req.ip);
|
||||||
|
res.json({ success: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check authentication status
|
||||||
|
router.get('/auth/status', (req, res) => {
|
||||||
|
if (req.session && req.session.userId) {
|
||||||
|
res.json({
|
||||||
|
authenticated: true,
|
||||||
|
user: {
|
||||||
|
id: req.session.userId,
|
||||||
|
username: req.session.username
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({ authenticated: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all nodes
|
||||||
|
router.get('/nodes', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const nodes = nodeQueries.getAllNodes.all();
|
||||||
|
res.json(nodes);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching nodes:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch nodes' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get specific node
|
||||||
|
router.get('/nodes/:nodeId', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { nodeId } = req.params;
|
||||||
|
const node = nodeQueries.getNodeById.get(nodeId);
|
||||||
|
|
||||||
|
if (!node) {
|
||||||
|
return res.status(404).json({ error: 'Node not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(node);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching node:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch node' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get latest positions for all nodes
|
||||||
|
router.get('/positions', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const positions = positionQueries.getLatestPositions.all();
|
||||||
|
res.json(positions);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching positions:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch positions' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get position trails for all nodes (last 10 positions per node)
|
||||||
|
// MUST come before /positions/:nodeId to avoid matching "trails" as a nodeId
|
||||||
|
router.get('/positions/trails/all', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
const trails = positionQueries.getPositionTrails.all(limit);
|
||||||
|
res.json(trails);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching position trails:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch position trails' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get position history for a specific node
|
||||||
|
router.get('/positions/:nodeId', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { nodeId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const positions = positionQueries.getPositionsByNode.all(nodeId, limit);
|
||||||
|
res.json(positions);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching position history:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch position history' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get recent messages (24 hours or up to 1000 messages, whichever is less)
|
||||||
|
router.get('/messages', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const limit = Math.min(parseInt(req.query.limit) || 1000, 1000);
|
||||||
|
const messages = messageQueries.getRecentMessagesWithTimeLimit.all(limit);
|
||||||
|
res.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching messages:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch messages' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get messages for a specific node
|
||||||
|
router.get('/messages/node/:nodeId', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { nodeId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const messages = messageQueries.getMessagesByNode.all(nodeId, nodeId, limit);
|
||||||
|
res.json(messages);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching node messages:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch node messages' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a message
|
||||||
|
router.post('/messages/send', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { text, channel } = req.body;
|
||||||
|
|
||||||
|
if (!text) {
|
||||||
|
return res.status(400).json({ error: 'Message text required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await mqttClient.sendTextMessage(text, channel || 0);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
logActivity(req.session.userId, 'send_message', text, req.ip);
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error sending message:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to send message' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get telemetry for a specific node
|
||||||
|
router.get('/telemetry/:nodeId', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { nodeId } = req.params;
|
||||||
|
const limit = parseInt(req.query.limit) || 100;
|
||||||
|
const telemetry = telemetryQueries.getTelemetryHistory.all(nodeId, limit);
|
||||||
|
res.json(telemetry);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching telemetry:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch telemetry' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get dashboard statistics
|
||||||
|
router.get('/stats', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const messageCount = statsQueries.getMessageCount.get();
|
||||||
|
const nodeCount = statsQueries.getNodeCount.get();
|
||||||
|
const positionCount = statsQueries.getPositionCount.get();
|
||||||
|
const dbSize = statsQueries.getDbSize();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
messages: messageCount.count,
|
||||||
|
nodes: nodeCount.count,
|
||||||
|
positions: positionCount.count,
|
||||||
|
databaseSize: dbSize,
|
||||||
|
mqttConnected: mqttClient.isConnected()
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error fetching stats:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to fetch stats' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Purge old data
|
||||||
|
router.post('/purge', requireAuth, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { days } = req.body;
|
||||||
|
const daysToKeep = days || 30;
|
||||||
|
|
||||||
|
const messagesDeleted = messageQueries.deleteOldMessages.run(daysToKeep);
|
||||||
|
const positionsDeleted = positionQueries.deleteOldPositions.run(daysToKeep);
|
||||||
|
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(daysToKeep);
|
||||||
|
|
||||||
|
logger.info(`Data purged: ${messagesDeleted.changes} messages, ${positionsDeleted.changes} positions, ${telemetryDeleted.changes} telemetry records`);
|
||||||
|
|
||||||
|
// Log activity
|
||||||
|
logActivity(
|
||||||
|
req.session.userId,
|
||||||
|
'purge_data',
|
||||||
|
`Purged data older than ${daysToKeep} days`,
|
||||||
|
req.ip
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
deleted: {
|
||||||
|
messages: messagesDeleted.changes,
|
||||||
|
positions: positionsDeleted.changes,
|
||||||
|
telemetry: telemetryDeleted.changes
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error purging data:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to purge data' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// MQTT status
|
||||||
|
router.get('/mqtt/status', requireAuth, (req, res) => {
|
||||||
|
res.json({
|
||||||
|
connected: mqttClient.isConnected()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
const readline = require('readline');
|
||||||
|
const { createUser } = require('../auth/auth');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
const rl = readline.createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
function question(query) {
|
||||||
|
return new Promise(resolve => rl.question(query, resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('\n=== Create New User ===\n');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const username = await question('Enter username: ');
|
||||||
|
|
||||||
|
if (!username || username.length < 3) {
|
||||||
|
console.error('Username must be at least 3 characters long');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await question('Enter password: ');
|
||||||
|
|
||||||
|
if (!password || password.length < 6) {
|
||||||
|
console.error('Password must be at least 6 characters long');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPassword = await question('Confirm password: ');
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
console.error('Passwords do not match');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await createUser(username, password);
|
||||||
|
|
||||||
|
console.log(`\nUser '${username}' created successfully!\n`);
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating user:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const session = require('express-session');
|
||||||
|
const helmet = require('helmet');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
const rateLimit = require('express-rate-limit');
|
||||||
|
|
||||||
|
const config = require('./config/config');
|
||||||
|
const logger = require('./utils/logger');
|
||||||
|
const apiRoutes = require('./routes/api');
|
||||||
|
const mqttClient = require('./mqtt/client');
|
||||||
|
const cronService = require('./services/cron');
|
||||||
|
|
||||||
|
// Initialize Express app
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(helmet({
|
||||||
|
contentSecurityPolicy: {
|
||||||
|
directives: {
|
||||||
|
defaultSrc: ["'self'"],
|
||||||
|
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
||||||
|
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com"],
|
||||||
|
imgSrc: ["'self'", "data:", "https:", "http:"],
|
||||||
|
connectSrc: ["'self'"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
app.use(cors({
|
||||||
|
origin: config.server.nodeEnv === 'production' ? false : true,
|
||||||
|
credentials: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Body parser
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
|
// Session configuration
|
||||||
|
app.use(session(config.session));
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: config.rateLimit.windowMs,
|
||||||
|
max: config.rateLimit.maxRequests,
|
||||||
|
message: 'Too many requests from this IP, please try again later.'
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/', limiter);
|
||||||
|
|
||||||
|
// Serve static files
|
||||||
|
app.use(express.static(path.join(__dirname, '..', 'public')));
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api', apiRoutes);
|
||||||
|
|
||||||
|
// Serve index.html for all other routes (SPA)
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error handler
|
||||||
|
app.use((err, req, res, next) => {
|
||||||
|
logger.error('Express error:', err);
|
||||||
|
res.status(500).json({ error: 'Internal server error' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
function start() {
|
||||||
|
const PORT = config.server.port;
|
||||||
|
|
||||||
|
// Connect to MQTT broker
|
||||||
|
logger.info('Starting MQTT client...');
|
||||||
|
mqttClient.connect();
|
||||||
|
|
||||||
|
// Start cron service
|
||||||
|
logger.info('Starting cron service...');
|
||||||
|
cronService.start();
|
||||||
|
|
||||||
|
// Start Express server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
logger.info(`Server running on http://localhost:${PORT}`);
|
||||||
|
logger.info(`Environment: ${config.server.nodeEnv}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
logger.info('Shutting down gracefully...');
|
||||||
|
|
||||||
|
mqttClient.disconnect();
|
||||||
|
cronService.stop();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
logger.info('Shutting down gracefully...');
|
||||||
|
|
||||||
|
mqttClient.disconnect();
|
||||||
|
cronService.stop();
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle uncaught exceptions
|
||||||
|
process.on('uncaughtException', (error) => {
|
||||||
|
logger.error('Uncaught exception:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, promise) => {
|
||||||
|
logger.error('Unhandled rejection at:', promise, 'reason:', reason);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the application
|
||||||
|
start();
|
||||||
|
|
||||||
|
module.exports = app;
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
const cron = require('node-cron');
|
||||||
|
const config = require('../config/config');
|
||||||
|
const { messageQueries, positionQueries, telemetryQueries } = require('../database/queries');
|
||||||
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
|
class CronService {
|
||||||
|
constructor() {
|
||||||
|
this.tasks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
// Schedule data purging
|
||||||
|
const purgeTask = cron.schedule(
|
||||||
|
config.dataRetention.purgeCronSchedule,
|
||||||
|
() => {
|
||||||
|
this.purgeOldData();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scheduled: true,
|
||||||
|
timezone: 'UTC'
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
this.tasks.push(purgeTask);
|
||||||
|
logger.info(`Cron job scheduled: Data purging at ${config.dataRetention.purgeCronSchedule}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
purgeOldData() {
|
||||||
|
try {
|
||||||
|
const days = config.dataRetention.days;
|
||||||
|
logger.info(`Starting automatic data purge (keeping last ${days} days)`);
|
||||||
|
|
||||||
|
const messagesDeleted = messageQueries.deleteOldMessages.run(days);
|
||||||
|
const positionsDeleted = positionQueries.deleteOldPositions.run(days);
|
||||||
|
const telemetryDeleted = telemetryQueries.deleteOldTelemetry.run(days);
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`Data purge completed: ${messagesDeleted.changes} messages, ` +
|
||||||
|
`${positionsDeleted.changes} positions, ` +
|
||||||
|
`${telemetryDeleted.changes} telemetry records deleted`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error during automatic data purge:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
this.tasks.forEach(task => task.stop());
|
||||||
|
logger.info('Cron service stopped');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new CronService();
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
const winston = require('winston');
|
||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
// Ensure logs directory exists
|
||||||
|
const logsDir = path.join(__dirname, '..', '..', 'logs');
|
||||||
|
if (!fs.existsSync(logsDir)) {
|
||||||
|
fs.mkdirSync(logsDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Define log format
|
||||||
|
const logFormat = winston.format.combine(
|
||||||
|
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
|
||||||
|
winston.format.errors({ stack: true }),
|
||||||
|
winston.format.printf(({ timestamp, level, message, stack }) => {
|
||||||
|
if (stack) {
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${message}\n${stack}`;
|
||||||
|
}
|
||||||
|
return `${timestamp} [${level.toUpperCase()}]: ${message}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create logger instance
|
||||||
|
const logger = winston.createLogger({
|
||||||
|
level: process.env.LOG_LEVEL || 'info',
|
||||||
|
format: logFormat,
|
||||||
|
transports: [
|
||||||
|
// Write all logs to console
|
||||||
|
new winston.transports.Console({
|
||||||
|
format: winston.format.combine(
|
||||||
|
winston.format.colorize(),
|
||||||
|
logFormat
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
// Write all logs to combined.log
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logsDir, 'combined.log'),
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
}),
|
||||||
|
// Write error logs to error.log
|
||||||
|
new winston.transports.File({
|
||||||
|
filename: path.join(logsDir, 'error.log'),
|
||||||
|
level: 'error',
|
||||||
|
maxsize: 5242880, // 5MB
|
||||||
|
maxFiles: 5
|
||||||
|
})
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create a stream object for Morgan HTTP logger
|
||||||
|
logger.stream = {
|
||||||
|
write: (message) => {
|
||||||
|
logger.info(message.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = logger;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// Test script to capture and display JSON messages
|
||||||
|
const mqtt = require('mqtt');
|
||||||
|
|
||||||
|
const client = mqtt.connect('mqtt://mqtt.meshtastic.org', {
|
||||||
|
username: 'meshdev',
|
||||||
|
password: 'large4cats',
|
||||||
|
clientId: `test-${Math.random().toString(16).substr(2, 8)}`
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log('Connected! Subscribing to JSON topics...');
|
||||||
|
client.subscribe('msh/US/+/2/json/#', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Subscribe error:', err);
|
||||||
|
} else {
|
||||||
|
console.log('Subscribed! Waiting for JSON messages...\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let messageCount = 0;
|
||||||
|
|
||||||
|
client.on('message', (topic, message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
messageCount++;
|
||||||
|
|
||||||
|
console.log(`\n=== Message #${messageCount} ===`);
|
||||||
|
console.log(`Topic: ${topic}`);
|
||||||
|
console.log(`Data:`, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
if (messageCount >= 5) {
|
||||||
|
console.log('\n\nCaptured 5 messages, exiting...');
|
||||||
|
client.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, skip
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Timeout - no messages received');
|
||||||
|
process.exit(1);
|
||||||
|
}, 30000);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Test script to capture nodeinfo messages
|
||||||
|
const mqtt = require('mqtt');
|
||||||
|
|
||||||
|
const client = mqtt.connect('mqtt://mqtt.meshtastic.org', {
|
||||||
|
username: 'meshdev',
|
||||||
|
password: 'large4cats',
|
||||||
|
clientId: `test-${Math.random().toString(16).substr(2, 8)}`
|
||||||
|
});
|
||||||
|
|
||||||
|
client.on('connect', () => {
|
||||||
|
console.log('Connected! Subscribing to JSON topics...');
|
||||||
|
client.subscribe('msh/US/+/2/json/#', (err) => {
|
||||||
|
if (err) {
|
||||||
|
console.error('Subscribe error:', err);
|
||||||
|
} else {
|
||||||
|
console.log('Subscribed! Waiting for nodeinfo messages...\n');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let nodeinfoCount = 0;
|
||||||
|
const seenTypes = new Set();
|
||||||
|
|
||||||
|
client.on('message', (topic, message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.toString());
|
||||||
|
|
||||||
|
if (!seenTypes.has(data.type)) {
|
||||||
|
seenTypes.add(data.type);
|
||||||
|
console.log(`\n=== New Message Type: ${data.type} ===`);
|
||||||
|
console.log(`Topic: ${topic}`);
|
||||||
|
console.log(`Data:`, JSON.stringify(data, null, 2));
|
||||||
|
console.log('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'nodeinfo' && nodeinfoCount < 3) {
|
||||||
|
nodeinfoCount++;
|
||||||
|
console.log(`\n=== NodeInfo Message #${nodeinfoCount} ===`);
|
||||||
|
console.log(`Topic: ${topic}`);
|
||||||
|
console.log(`Data:`, JSON.stringify(data, null, 2));
|
||||||
|
console.log('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodeinfoCount >= 3 && seenTypes.size >= 5) {
|
||||||
|
console.log('Captured enough samples, exiting...');
|
||||||
|
client.end();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Not JSON, skip
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Timeout - captured types:', Array.from(seenTypes));
|
||||||
|
process.exit(1);
|
||||||
|
}, 60000);
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
// Quick setup script for testing
|
||||||
|
const { createUser } = require('./src/auth/auth');
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
try {
|
||||||
|
console.log('Creating test user...');
|
||||||
|
await createUser('admin', 'password123');
|
||||||
|
console.log('✓ User "admin" created successfully!');
|
||||||
|
console.log('✓ You can now start the server with: npm start');
|
||||||
|
console.log('✓ Login credentials: admin / password123');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.message.includes('already exists')) {
|
||||||
|
console.log('✓ User "admin" already exists');
|
||||||
|
console.log('✓ You can now start the server with: npm start');
|
||||||
|
console.log('✓ Login credentials: admin / password123');
|
||||||
|
process.exit(0);
|
||||||
|
} else {
|
||||||
|
console.error('Error:', error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setup();
|
||||||
Reference in New Issue
Block a user