From 4767b674607ae284bcaf18b027b452fbb01aa04f Mon Sep 17 00:00:00 2001 From: Will Bradley Date: Sat, 11 Oct 2025 17:03:31 -0700 Subject: [PATCH] initial commit --- .env.example | 31 + .gitignore | 37 + DEPLOYMENT.md | 413 ++++++++++++ PROJECT_SUMMARY.md | 281 ++++++++ QUICKSTART.md | 107 +++ README.md | 339 ++++++++++ STRUCTURE.txt | 55 ++ check-data.js | 49 ++ package.json | 37 + public/css/style.css | 596 ++++++++++++++++ public/index.html | 196 ++++++ public/js/app.js | 1347 +++++++++++++++++++++++++++++++++++++ src/auth/auth.js | 87 +++ src/config/config.js | 53 ++ src/database/db.js | 150 +++++ src/database/queries.js | 244 +++++++ src/mqtt/client.js | 401 +++++++++++ src/routes/api.js | 265 ++++++++ src/scripts/createUser.js | 51 ++ src/server.js | 121 ++++ src/services/cron.js | 53 ++ src/utils/logger.js | 58 ++ test-json.js | 45 ++ test-nodeinfo.js | 57 ++ test-setup.js | 25 + 25 files changed, 5098 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 PROJECT_SUMMARY.md create mode 100644 QUICKSTART.md create mode 100644 README.md create mode 100644 STRUCTURE.txt create mode 100644 check-data.js create mode 100644 package.json create mode 100644 public/css/style.css create mode 100644 public/index.html create mode 100644 public/js/app.js create mode 100644 src/auth/auth.js create mode 100644 src/config/config.js create mode 100644 src/database/db.js create mode 100644 src/database/queries.js create mode 100644 src/mqtt/client.js create mode 100644 src/routes/api.js create mode 100644 src/scripts/createUser.js create mode 100644 src/server.js create mode 100644 src/services/cron.js create mode 100644 src/utils/logger.js create mode 100644 test-json.js create mode 100644 test-nodeinfo.js create mode 100644 test-setup.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..27cd91e --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5f31246 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..15bf008 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 diff --git a/PROJECT_SUMMARY.md b/PROJECT_SUMMARY.md new file mode 100644 index 0000000..915a144 --- /dev/null +++ b/PROJECT_SUMMARY.md @@ -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** diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..cfc261d --- /dev/null +++ b/QUICKSTART.md @@ -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! diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c11365 --- /dev/null +++ b/README.md @@ -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. + +![Dashboard Preview](https://via.placeholder.com/800x400?text=Meshtastic+Dashboard) + +## 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 diff --git a/STRUCTURE.txt b/STRUCTURE.txt new file mode 100644 index 0000000..99a90b7 --- /dev/null +++ b/STRUCTURE.txt @@ -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 + diff --git a/check-data.js b/check-data.js new file mode 100644 index 0000000..47c93fd --- /dev/null +++ b/check-data.js @@ -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); +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..22daf1a --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/css/style.css b/public/css/style.css new file mode 100644 index 0000000..62216bd --- /dev/null +++ b/public/css/style.css @@ -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); + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..36220ee --- /dev/null +++ b/public/index.html @@ -0,0 +1,196 @@ + + + + + + Meshtastic MQTT Dashboard + + + + + +
+ +
+ + + + + + + + + + diff --git a/public/js/app.js b/public/js/app.js new file mode 100644 index 0000000..520a2bd --- /dev/null +++ b/public/js/app.js @@ -0,0 +1,1347 @@ +// Application State +const state = { + authenticated: false, + user: null, + map: null, + markers: {}, + trails: {}, // Track position trail polylines for each node + nodeLastHeard: {}, // Track last heard times for flash detection + refreshIntervals: [], + timeUpdateInterval: null, + lastSuccessfulUpdate: Date.now(), + lastDataReceived: Date.now(), // Track when actual data was received + mqttConnected: true, + connectionWarningShown: false, + mapUserMoved: false, // Track if user has moved the map + knownMessages: new Set(), // Track known message IDs + knownNodes: new Map(), // Track known nodes with their data + reconnectAttempts: 0, + maxReconnectAttempts: 5, + reconnectDelay: 5000, // 5 seconds + isReconnecting: false +}; + +// Handle authentication errors (401) +function handleAuthenticationError() { + if (!state.authenticated) return; // Already logged out + + console.log('Session expired - logging out'); + state.authenticated = false; + state.user = null; + stopRefreshIntervals(); + + // Show notification + alert('Your session has expired. Please log in again.'); + + // Redirect to login + ui.showScreen('login-screen'); +} + +// Handle connection lost (network errors) +function handleConnectionLost() { + if (state.isReconnecting) return; // Already trying to reconnect + + state.isReconnecting = true; + + // Show notification + if (!state.connectionWarningShown) { + showNotification('Connection Lost', 'Attempting to reconnect to server...', 'warning'); + } + + // Try to reconnect + attemptReconnect(); +} + +// Attempt to reconnect to the server +async function attemptReconnect() { + if (state.reconnectAttempts >= state.maxReconnectAttempts) { + state.isReconnecting = false; + showNotification('Connection Failed', 'Unable to reconnect. Please refresh the page.', 'error'); + return; + } + + state.reconnectAttempts++; + console.log(`Reconnection attempt ${state.reconnectAttempts} of ${state.maxReconnectAttempts}`); + + // Wait before attempting + await new Promise(resolve => setTimeout(resolve, state.reconnectDelay)); + + try { + // Try to check auth status to test connection + const response = await fetch('/api/auth/status', { + credentials: 'include' + }); + + if (response.ok) { + console.log('Reconnected to server'); + state.reconnectAttempts = 0; + state.isReconnecting = false; + showNotification('Connection Restored', 'Successfully reconnected to server', 'success'); + + // Reload current tab data + const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab'); + if (activeTab === 'overview') loadOverview(); + if (activeTab === 'messages') loadMessages(); + if (activeTab === 'nodes') loadNodes(); + if (activeTab === 'map') loadMap(); + } else if (response.status === 401) { + // Session expired + handleAuthenticationError(); + state.isReconnecting = false; + } else { + // Still can't connect, try again + attemptReconnect(); + } + } catch (error) { + // Still can't connect, try again + attemptReconnect(); + } +} + +// API Helper +const api = { + async request(url, options = {}) { + try { + const response = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + }, + credentials: 'include' + }); + + // Handle 401 Unauthorized - session expired or invalid + if (response.status === 401) { + console.warn('Authentication required - session expired'); + handleAuthenticationError(); + throw new Error('Authentication required'); + } + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: 'Request failed' })); + throw new Error(error.error || 'Request failed'); + } + + return response.json(); + } catch (error) { + // Check if it's a network error (connection lost) + if (error.message === 'Failed to fetch' || error.name === 'TypeError') { + console.warn('Connection to server lost - attempting to reconnect'); + handleConnectionLost(); + } + throw error; + } + }, + + async login(username, password) { + return this.request('/api/login', { + method: 'POST', + body: JSON.stringify({ username, password }) + }); + }, + + async logout() { + return this.request('/api/logout', { method: 'POST' }); + }, + + async checkAuth() { + return this.request('/api/auth/status'); + }, + + async getStats() { + return this.request('/api/stats'); + }, + + async getNodes() { + return this.request('/api/nodes'); + }, + + async getNode(nodeId) { + return this.request(`/api/nodes/${nodeId}`); + }, + + async getPositions() { + return this.request('/api/positions'); + }, + + async getPositionTrails(limit = 10) { + return this.request(`/api/positions/trails/all?limit=${limit}`); + }, + + async getMessages(limit = 100) { + return this.request(`/api/messages?limit=${limit}`); + }, + + async sendMessage(text, channel = 0) { + return this.request('/api/messages/send', { + method: 'POST', + body: JSON.stringify({ text, channel }) + }); + }, + + async purgeData(days) { + return this.request('/api/purge', { + method: 'POST', + body: JSON.stringify({ days }) + }); + }, + + async getMqttStatus() { + return this.request('/api/mqtt/status'); + } +}; + +// UI Helper +const ui = { + showScreen(screenId) { + document.querySelectorAll('.screen').forEach(screen => { + screen.classList.add('hidden'); + }); + document.getElementById(screenId).classList.remove('hidden'); + }, + + showTab(tabName) { + document.querySelectorAll('.nav-tab').forEach(tab => { + tab.classList.remove('active'); + }); + document.querySelectorAll('.tab-content').forEach(content => { + content.classList.remove('active'); + }); + + document.querySelector(`[data-tab="${tabName}"]`)?.classList.add('active'); + document.getElementById(`${tabName}-tab`)?.classList.add('active'); + + // Reinitialize map when tab becomes visible + if (tabName === 'map') { + setTimeout(() => { + if (state.map) { + state.map.invalidateSize(); + } else { + initMap(); + } + }, 50); + } + }, + + showError(elementId, message) { + const element = document.getElementById(elementId); + if (element) { + element.textContent = message; + element.style.display = 'block'; + } + }, + + hideError(elementId) { + const element = document.getElementById(elementId); + if (element) { + element.textContent = ''; + element.style.display = 'none'; + } + }, + + formatRelativeTime(dateString) { + if (!dateString) return 'Never'; + + // Handle SQLite datetime format (YYYY-MM-DD HH:MM:SS) + // Convert to ISO format if needed + let dateStr = dateString; + if (dateString.includes(' ') && !dateString.includes('T')) { + dateStr = dateString.replace(' ', 'T') + 'Z'; + } + + const date = new Date(dateStr); + + // Check if date is valid + if (isNaN(date.getTime())) { + console.warn('Invalid date:', dateString); + return 'Invalid date'; + } + + const now = new Date(); + const diff = Math.floor((now - date) / 1000); // seconds + + if (diff < 1) return '0s ago'; + if (diff < 60) return `${diff}s ago`; + if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; + if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; + if (diff < 604800) return `${Math.floor(diff / 86400)}d ago`; + + return date.toLocaleDateString(); + }, + + formatBytes(bytes) { + if (bytes === 0) return '0 Bytes'; + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]; + }, + + getMarkerOpacity(lastHeardDate) { + if (!lastHeardDate) return 0.3; + const now = new Date(); + const lastHeard = new Date(lastHeardDate); + const hoursSince = (now - lastHeard) / (1000 * 60 * 60); + + if (hoursSince < 1) return 1.0; + if (hoursSince < 6) return 0.8; + if (hoursSince < 24) return 0.6; + if (hoursSince < 72) return 0.4; + return 0.2; + }, + + getMarkerColor(lastHeardDate) { + if (!lastHeardDate) return '#a50026'; // Darkest red for unknown + + // Handle SQLite datetime format + let dateStr = lastHeardDate; + if (lastHeardDate.includes(' ') && !lastHeardDate.includes('T')) { + dateStr = lastHeardDate.replace(' ', 'T') + 'Z'; + } + + const now = new Date(); + const lastHeard = new Date(dateStr); + const minutesSince = (now - lastHeard) / (1000 * 60); + + // Custom color scheme: Pure green -> Yellow -> Orange -> Red + const colors = [ + '#00ff00', // Pure green - 0-45 min + '#9db800', // Darker yellow-green - 45-90 min + '#cccc00', // Darker yellow - 90-135 min + '#fee08b', // Yellow-orange - 135-180 min + '#fdae61', // Orange - 180-225 min + '#f46d43', // Light red - 225-270 min + '#d73027', // Red - 270-315 min + '#a50026' // Dark red - 315-360 min (6h+) + ]; + + // 8 buckets over 360 minutes = 45 minutes per bucket + const bucketSize = 360 / 8; + const bucketIndex = Math.min(7, Math.floor(minutesSince / bucketSize)); + + return colors[bucketIndex]; + } +}; + +// Login Handler +document.getElementById('login-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + ui.hideError('login-error'); + + const username = document.getElementById('username').value; + const password = document.getElementById('password').value; + + try { + const result = await api.login(username, password); + if (result.success) { + state.authenticated = true; + state.user = result.user; + initDashboard(); + } + } catch (error) { + ui.showError('login-error', error.message); + } +}); + +// Logout Handler +document.getElementById('logout-btn')?.addEventListener('click', async () => { + try { + await api.logout(); + state.authenticated = false; + state.user = null; + stopRefreshIntervals(); + ui.showScreen('login-screen'); + } catch (error) { + console.error('Logout error:', error); + } +}); + +// Tab Navigation +document.querySelectorAll('.nav-tab').forEach(tab => { + tab.addEventListener('click', () => { + const tabName = tab.getAttribute('data-tab'); + ui.showTab(tabName); + + // Load tab content + if (tabName === 'overview') loadOverview(); + if (tabName === 'map') loadMap(); + if (tabName === 'messages') loadMessages(); + if (tabName === 'nodes') loadNodes(); + }); +}); + +// Initialize Dashboard +async function initDashboard() { + ui.showScreen('dashboard-screen'); + + // Set user info + document.getElementById('user-info').textContent = state.user.username; + + // Update MQTT status + updateMqttStatus(); + + // Load initial content + loadOverview(); + + // Start auto-refresh + startRefreshIntervals(); +} + +// Update MQTT Status +async function updateMqttStatus() { + try { + const status = await api.getMqttStatus(); + const statusElement = document.querySelector('#mqtt-status .status-dot'); + + // Update connection state + state.lastSuccessfulUpdate = Date.now(); + state.connectionWarningShown = false; + hideConnectionWarning(); + + if (status.connected) { + statusElement?.classList.add('connected'); + if (!state.mqttConnected) { + // MQTT reconnected + state.mqttConnected = true; + showNotification('MQTT Connected', 'Connection to MQTT broker restored', 'success'); + } + } else { + statusElement?.classList.remove('connected'); + if (state.mqttConnected) { + // MQTT disconnected + state.mqttConnected = false; + showNotification('MQTT Disconnected', 'Connection to MQTT broker lost', 'warning'); + } + } + } catch (error) { + console.error('Error fetching MQTT status:', error); + checkConnectionHealth(); + } +} + +// Load Overview +async function loadOverview() { + try { + const stats = await api.getStats(); + + document.getElementById('stat-nodes').textContent = stats.nodes; + document.getElementById('stat-messages').textContent = stats.messages; + document.getElementById('stat-positions').textContent = stats.positions; + document.getElementById('stat-db-size').textContent = ui.formatBytes(stats.databaseSize); + + // Load recent messages + const messages = await api.getMessages(10); + displayMessages(messages, 'recent-messages'); + + // Update last data received timestamp + state.lastDataReceived = Date.now(); + + // Reset reconnect attempts on successful load + if (state.reconnectAttempts > 0) { + state.reconnectAttempts = 0; + state.isReconnecting = false; + } + } catch (error) { + console.error('Error loading overview:', error); + } +} + +// Load Messages (24 hours or 1000 messages, whichever is less) +async function loadMessages() { + try { + const messages = await api.getMessages(1000); + displayMessages(messages, 'message-history'); + state.lastDataReceived = Date.now(); + + // Reset reconnect attempts on successful load + if (state.reconnectAttempts > 0) { + state.reconnectAttempts = 0; + state.isReconnecting = false; + } + } catch (error) { + console.error('Error loading messages:', error); + } +} + +// Display Messages +function displayMessages(messages, containerId) { + const container = document.getElementById(containerId); + if (!container) return; + + if (messages.length === 0) { + container.innerHTML = '
💬

No messages yet

'; + return; + } + + // Build a map of message IDs to messages + const messageMap = new Map(); + messages.forEach(msg => { + const msgId = msg.id || `${msg.from_node}-${msg.created_at}`; + messageMap.set(msgId, msg); + }); + + // Get existing message elements + const existingMessages = container.querySelectorAll('.message-item'); + const existingIds = new Set(); + + existingMessages.forEach(elem => { + const msgId = elem.getAttribute('data-message-id'); + if (msgId) existingIds.add(msgId); + }); + + // Remove messages that are no longer in the list (older than limit) + existingMessages.forEach(elem => { + const msgId = elem.getAttribute('data-message-id'); + if (msgId && !messageMap.has(msgId)) { + elem.remove(); + } + }); + + // Add new messages or update existing ones + messages.forEach((msg, index) => { + const msgId = msg.id || `${msg.from_node}-${msg.created_at}`; + + if (!existingIds.has(msgId)) { + // Create new message element + const messageHtml = ` +
+
+ + ${escapeHtml(msg.from_long_name || msg.from_short_name || msg.from_node)} + + ${ui.formatRelativeTime(msg.created_at)} +
+
${escapeHtml(msg.text || '')}
+ ${msg.rx_snr || msg.rx_rssi ? ` +
+ ${msg.rx_snr ? `SNR: ${msg.rx_snr.toFixed(1)} dB` : ''} + ${msg.rx_rssi ? ` | RSSI: ${msg.rx_rssi} dBm` : ''} + ${msg.channel !== null ? ` | Channel: ${msg.channel}` : ''} +
+ ` : ''} +
+ `; + + // Insert at the beginning (newest first) + if (index === 0) { + container.insertAdjacentHTML('afterbegin', messageHtml); + } else { + container.insertAdjacentHTML('beforeend', messageHtml); + } + } + }); + + // Reorder if necessary (messages should be newest first) + const allMessages = Array.from(container.querySelectorAll('.message-item')); + if (allMessages.length !== messages.length || allMessages.length > messages.length) { + // Prune to keep only the message limit + const limit = messages.length; + allMessages.slice(limit).forEach(elem => elem.remove()); + } +} + +// Send Message +document.getElementById('send-message-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + + const text = document.getElementById('message-text').value; + const channel = parseInt(document.getElementById('message-channel').value); + + try { + await api.sendMessage(text, channel); + document.getElementById('message-text').value = ''; + + // Reload messages after a short delay + setTimeout(() => loadMessages(), 1000); + } catch (error) { + alert('Error sending message: ' + error.message); + } +}); + +// Refresh Messages Button +document.getElementById('refresh-messages-btn')?.addEventListener('click', loadMessages); + +// Load Nodes +async function loadNodes() { + try { + const nodes = await api.getNodes(); + displayNodes(nodes); + state.lastDataReceived = Date.now(); + + // Reset reconnect attempts on successful load + if (state.reconnectAttempts > 0) { + state.reconnectAttempts = 0; + state.isReconnecting = false; + } + } catch (error) { + console.error('Error loading nodes:', error); + } +} + +// Display Nodes +function displayNodes(nodes) { + const container = document.getElementById('nodes-grid'); + if (!container) return; + + if (nodes.length === 0) { + container.innerHTML = '
📡

No nodes found

'; + return; + } + + // Build a map of node IDs to nodes + const nodeMap = new Map(); + nodes.forEach(node => { + nodeMap.set(node.node_id, node); + }); + + // Get existing node elements + const existingNodes = container.querySelectorAll('.node-card'); + const existingIds = new Set(); + + existingNodes.forEach(elem => { + const nodeId = elem.getAttribute('data-node-id'); + if (nodeId) existingIds.add(nodeId); + }); + + // Remove nodes that are no longer in the list + existingNodes.forEach(elem => { + const nodeId = elem.getAttribute('data-node-id'); + if (nodeId && !nodeMap.has(nodeId)) { + elem.remove(); + } + }); + + // Add new nodes or update existing ones + nodes.forEach(node => { + const lastHeard = node.last_heard ? new Date(node.last_heard) : null; + const isOnline = lastHeard && (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes + + if (existingIds.has(node.node_id)) { + // Update existing node card content + const card = container.querySelector(`[data-node-id="${node.node_id}"]`); + if (card) { + // Update node name + const nameElem = card.querySelector('.node-name'); + if (nameElem) nameElem.textContent = node.long_name || node.short_name || 'Unknown'; + + // Update badge + const badge = card.querySelector('.node-badge'); + if (badge) { + badge.className = `node-badge ${isOnline ? 'online' : 'offline'}`; + badge.textContent = isOnline ? 'Online' : 'Offline'; + } + + // Update last heard time + const timeElem = card.querySelector('.relative-time'); + if (timeElem && node.last_heard) { + timeElem.setAttribute('data-timestamp', node.last_heard); + timeElem.textContent = ui.formatRelativeTime(node.last_heard); + } + + // Update battery if present + const nodeInfo = card.querySelector('.node-info'); + if (nodeInfo && node.battery_level) { + // Check if battery row already exists + let batteryRow = null; + nodeInfo.querySelectorAll('.node-info-row').forEach(row => { + const label = row.querySelector('span:first-child'); + if (label && label.textContent.includes('Battery:')) { + batteryRow = row; + } + }); + + if (!batteryRow) { + nodeInfo.insertAdjacentHTML('beforeend', + `
Battery:${node.battery_level}%
` + ); + } else { + // Update existing battery value + const valueSpan = batteryRow.querySelector('span:last-child'); + if (valueSpan) valueSpan.textContent = `${node.battery_level}%`; + } + } + + // Update or add globe button if location is available + const headerDiv = card.querySelector('.node-card-header > div:last-child'); + if (headerDiv && node.latitude && node.longitude) { + let globeButton = headerDiv.querySelector('a[href*="openstreetmap"]'); + if (!globeButton) { + const globeHtml = `🌍`; + const badge = headerDiv.querySelector('.node-badge'); + if (badge) { + badge.insertAdjacentHTML('beforebegin', globeHtml); + } + } else { + // Update href if coordinates changed + globeButton.href = `https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=11`; + } + } else if (headerDiv && !node.latitude) { + // Remove globe button if location is no longer available + const globeButton = headerDiv.querySelector('a[href*="openstreetmap"]'); + if (globeButton) { + globeButton.remove(); + } + } + } + } else { + // Create new node card + const nodeHtml = ` +
+
+
+
${escapeHtml(node.long_name || node.short_name || 'Unknown')}
+
${escapeHtml(node.node_id)}
+
+
+ ${node.latitude && node.longitude ? ` + 🌍 + ` : ''} + + ${isOnline ? 'Online' : 'Offline'} + +
+
+
+ ${node.hardware_model ? `
Model:${escapeHtml(String(node.hardware_model))}
` : ''} + ${node.battery_level ? `
Battery:${node.battery_level}%
` : ''} + ${node.last_heard ? `
Last Heard:${ui.formatRelativeTime(node.last_heard)}
` : ''} +
+
+ `; + + container.insertAdjacentHTML('beforeend', nodeHtml); + + // Add click handler for the new card + const newCard = container.querySelector(`[data-node-id="${node.node_id}"]`); + if (newCard) { + newCard.addEventListener('click', () => { + window.showNodeDetail(node.node_id); + }); + } + } + }); +} + +// Show Node Detail (make it globally accessible for Leaflet popups) +window.showNodeDetail = async function(nodeId) { + try { + const node = await api.getNode(nodeId); + const modal = document.getElementById('node-modal'); + const detailContainer = document.getElementById('node-detail'); + + detailContainer.innerHTML = ` +
+ Node ID + ${escapeHtml(node.node_id)} +
+ ${node.short_name ? ` +
+ Short Name + ${escapeHtml(node.short_name)} +
+ ` : ''} + ${node.long_name ? ` +
+ Long Name + ${escapeHtml(node.long_name)} +
+ ` : ''} + ${node.hardware_model ? ` +
+ Hardware Model + ${escapeHtml(String(node.hardware_model))} +
+ ` : ''} + ${node.role ? ` +
+ Role + ${escapeHtml(String(node.role))} +
+ ` : ''} + ${node.firmware_version ? ` +
+ Firmware + ${escapeHtml(node.firmware_version)} +
+ ` : ''} + ${node.latitude && node.longitude ? ` +
+ Location + + ${node.latitude.toFixed(6)}, ${node.longitude.toFixed(6)} + 🌍 + +
+ ` : ''} + ${node.battery_level ? ` +
+ Battery Level + ${node.battery_level}% +
+ ` : ''} + ${node.voltage ? ` +
+ Voltage + ${node.voltage.toFixed(2)}V +
+ ` : ''} + ${node.channel_utilization ? ` +
+ Channel Utilization + ${node.channel_utilization.toFixed(1)}% +
+ ` : ''} + ${node.last_heard ? ` +
+ Last Heard + ${ui.formatRelativeTime(node.last_heard)} +
+ ` : ''} + `; + + modal?.classList.remove('hidden'); + } catch (error) { + console.error('Error loading node detail:', error); + alert('Error loading node details'); + } +} + +// Close Node Modal +document.querySelector('.modal-close')?.addEventListener('click', () => { + document.getElementById('node-modal')?.classList.add('hidden'); +}); + +document.getElementById('node-modal')?.addEventListener('click', (e) => { + if (e.target.id === 'node-modal') { + document.getElementById('node-modal')?.classList.add('hidden'); + } +}); + +// Refresh Nodes Button +document.getElementById('refresh-nodes-btn')?.addEventListener('click', loadNodes); + +// Initialize Map +function initMap() { + if (state.map) return; + + const mapElement = document.getElementById('map'); + if (!mapElement) return; + + state.map = L.map('map').setView([39.8283, -98.5795], 4); // Center of USA + + // Use CartoDB Positron for minimalist black and white tiles + L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { + attribution: '© OpenStreetMap contributors, © CARTO', + maxZoom: 19, + subdomains: 'abcd' + }).addTo(state.map); + + // Track when user moves the map + state.map.on('movestart', () => { + state.mapUserMoved = true; + }); + + // Force resize after initialization + setTimeout(() => state.map.invalidateSize(), 100); +} + +// Load Map +async function loadMap() { + if (!state.map) { + initMap(); + } + + try { + const [positions, trails] = await Promise.all([ + api.getPositions(), + api.getPositionTrails(10) + ]); + displayPositions(positions); + displayTrails(trails); + state.lastDataReceived = Date.now(); + + // Reset reconnect attempts on successful load + if (state.reconnectAttempts > 0) { + state.reconnectAttempts = 0; + state.isReconnecting = false; + } + } catch (error) { + console.error('Error loading map:', error); + } +} + +// Display Positions on Map +function displayPositions(positions) { + if (!state.map) { + initMap(); + } + + if (!state.map) return; + + if (positions.length === 0) { + return; + } + + // Track which nodes we've seen in this update + const seenNodes = new Set(); + + // Update or add markers for each position + positions.forEach(pos => { + seenNodes.add(pos.node_id); + const opacity = ui.getMarkerOpacity(pos.last_heard || pos.timestamp); + const color = ui.getMarkerColor(pos.last_heard || pos.timestamp); + + // Check if node is newly heard (last_heard changed recently) + const currentLastHeard = pos.last_heard || pos.timestamp; + const previousLastHeard = state.nodeLastHeard[pos.node_id]; + const isNewlyHeard = previousLastHeard && currentLastHeard !== previousLastHeard; + + // Update tracked last heard time + state.nodeLastHeard[pos.node_id] = currentLastHeard; + + // Add pulsing animation for newly heard nodes + const pulseAnimation = isNewlyHeard ? 'animation: markerPulse 2s ease-out;' : ''; + + const icon = L.divIcon({ + className: 'custom-marker', + html: `
`, + iconSize: [16, 16], + iconAnchor: [8, 8] + }); + + const popupContent = ` +
+ ${escapeHtml(pos.long_name || pos.short_name || pos.node_id)}
+ ${escapeHtml(pos.node_id)}
+ Lat: ${pos.latitude.toFixed(6)}
+ Lon: ${pos.longitude.toFixed(6)}
+ ${pos.altitude ? `Alt: ${pos.altitude}m
` : ''} + ${ui.formatRelativeTime(pos.timestamp)} +
+ View Details + 🌍 +
+
+ `; + + // Update existing marker or create new one + if (state.markers[pos.node_id]) { + const marker = state.markers[pos.node_id]; + const currentLatLng = marker.getLatLng(); + + // Update position if changed + if (currentLatLng.lat !== pos.latitude || currentLatLng.lng !== pos.longitude) { + marker.setLatLng([pos.latitude, pos.longitude]); + } + + // Update icon opacity + marker.setIcon(icon); + + // Update popup content without destroying the marker + marker.getPopup().setContent(popupContent); + } else { + // Create new marker + const marker = L.marker([pos.latitude, pos.longitude], { icon }) + .addTo(state.map) + .bindPopup(popupContent); + + state.markers[pos.node_id] = marker; + } + }); + + // Remove markers for nodes that are no longer in the position list (older than retention) + Object.keys(state.markers).forEach(nodeId => { + if (!seenNodes.has(nodeId)) { + state.markers[nodeId].remove(); + delete state.markers[nodeId]; + } + }); + + // Only fit bounds if user hasn't moved the map and this is the first load + if (!state.mapUserMoved && positions.length > 0 && Object.keys(state.markers).length === positions.length) { + const bounds = positions.map(pos => [pos.latitude, pos.longitude]); + state.map.fitBounds(bounds, { padding: [50, 50] }); + } + + // Force resize + setTimeout(() => state.map.invalidateSize(), 100); +} + +// Display Position Trails on Map +function displayTrails(trails) { + if (!state.map || !trails || trails.length === 0) return; + + // Group trails by node_id + const trailsByNode = {}; + trails.forEach(pos => { + if (!trailsByNode[pos.node_id]) { + trailsByNode[pos.node_id] = []; + } + trailsByNode[pos.node_id].push(pos); + }); + + // Track which nodes we've seen + const seenNodes = new Set(); + + // Create or update polylines for each node + Object.entries(trailsByNode).forEach(([nodeId, positions]) => { + seenNodes.add(nodeId); + + // Only draw trail if we have at least 2 positions + if (positions.length < 2) return; + + // Sort by timestamp (oldest to newest) for drawing the line + positions.sort((a, b) => new Date(a.timestamp) - new Date(b.timestamp)); + + // Create coordinate array for polyline + const latLngs = positions.map(pos => [pos.latitude, pos.longitude]); + + // Remove existing trail if it exists + if (state.trails[nodeId]) { + state.trails[nodeId].remove(); + } + + // Create new polyline (trail) + const polyline = L.polyline(latLngs, { + color: '#666', + weight: 2, + opacity: 0.5, + dashArray: '5, 5' + }).addTo(state.map); + + state.trails[nodeId] = polyline; + }); + + // Remove trails for nodes that are no longer in the list + Object.keys(state.trails).forEach(nodeId => { + if (!seenNodes.has(nodeId)) { + state.trails[nodeId].remove(); + delete state.trails[nodeId]; + } + }); +} + +// Refresh Map Button +document.getElementById('refresh-map-btn')?.addEventListener('click', loadMap); + +// Purge Data +document.getElementById('purge-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + + const days = parseInt(document.getElementById('purge-days').value); + + if (!confirm(`Are you sure you want to delete data older than ${days} days? This cannot be undone.`)) { + return; + } + + try { + const result = await api.purgeData(days); + alert(`Successfully purged:\n${result.deleted.messages} messages\n${result.deleted.positions} positions\n${result.deleted.telemetry} telemetry records`); + + // Reload stats + loadOverview(); + } catch (error) { + alert('Error purging data: ' + error.message); + } +}); + +// Update all relative times +function updateRelativeTimes() { + document.querySelectorAll('.relative-time, .message-time').forEach(element => { + const timestamp = element.getAttribute('data-timestamp'); + if (timestamp) { + element.textContent = ui.formatRelativeTime(timestamp); + } + }); +} + +// Update data last received timer +function updateDataTimer() { + const timerElement = document.getElementById('data-timer'); + if (!timerElement) return; + + const timeSinceData = Math.floor((Date.now() - state.lastDataReceived) / 1000); + + let timeText; + if (timeSinceData < 1) { + timeText = '0s ago'; + } else if (timeSinceData < 60) { + timeText = `${timeSinceData}s ago`; + } else if (timeSinceData < 3600) { + timeText = `${Math.floor(timeSinceData / 60)}m ago`; + } else if (timeSinceData < 86400) { + timeText = `${Math.floor(timeSinceData / 3600)}h ago`; + } else { + timeText = `${Math.floor(timeSinceData / 86400)}d ago`; + } + + timerElement.textContent = timeText; + + // Visual warning if data is stale + const dataLastReceivedElem = document.getElementById('data-last-received'); + if (dataLastReceivedElem) { + if (timeSinceData > 30) { + dataLastReceivedElem.style.color = '#ff9800'; // Orange + } else if (timeSinceData > 60) { + dataLastReceivedElem.style.color = '#f44336'; // Red + } else { + dataLastReceivedElem.style.color = ''; // Default + } + } +} + +// Update node online/offline badges +function updateNodeBadges() { + document.querySelectorAll('.node-card').forEach(card => { + const lastHeardElement = card.querySelector('.relative-time'); + if (!lastHeardElement) return; + + const lastHeardTimestamp = lastHeardElement.getAttribute('data-timestamp'); + if (!lastHeardTimestamp) return; + + const lastHeard = new Date(lastHeardTimestamp); + const isOnline = (Date.now() - lastHeard.getTime()) < 900000; // 15 minutes + + const badge = card.querySelector('.node-badge'); + if (badge) { + badge.className = `node-badge ${isOnline ? 'online' : 'offline'}`; + badge.textContent = isOnline ? 'Online' : 'Offline'; + } + }); +} + +// Update map marker opacity and relative times in popups +function updateMapMarkerOpacity() { + if (!state.map || Object.keys(state.markers).length === 0) return; + + Object.entries(state.markers).forEach(([nodeId, marker]) => { + // Get the marker's popup content to extract timestamp + const popupContent = marker.getPopup()?.getContent(); + if (!popupContent) return; + + // Extract timestamp from popup + const timestampMatch = popupContent.match(/data-timestamp="([^"]+)"/); + if (!timestampMatch) return; + + const timestamp = timestampMatch[1]; + const opacity = ui.getMarkerOpacity(timestamp); + const color = ui.getMarkerColor(timestamp); + + // Update marker icon with new opacity and color (only if it changed significantly) + const currentIcon = marker.getIcon(); + const currentOpacity = currentIcon?.options?.html?.match(/opacity: ([\d.]+)/)?.[1]; + const currentColor = currentIcon?.options?.html?.match(/background-color: ([^;]+);/)?.[1]; + + if (!currentOpacity || Math.abs(parseFloat(currentOpacity) - opacity) > 0.05 || currentColor !== color) { + const icon = L.divIcon({ + className: 'custom-marker', + html: `
`, + iconSize: [16, 16], + iconAnchor: [8, 8] + }); + marker.setIcon(icon); + } + + // Update relative time in popup without recreating the popup + const relativeTime = ui.formatRelativeTime(timestamp); + const updatedContent = popupContent.replace( + /.*?<\/span>/, + `${relativeTime}` + ); + + // Only update if content changed + if (updatedContent !== popupContent) { + marker.getPopup().setContent(updatedContent); + } + }); +} + +// Check connection health +function checkConnectionHealth() { + const timeSinceLastUpdate = Date.now() - state.lastSuccessfulUpdate; + + // If we haven't had a successful update in 10 seconds, show warning + if (timeSinceLastUpdate > 10000 && !state.connectionWarningShown) { + state.connectionWarningShown = true; + showConnectionWarning(); + } +} + +// Show connection warning +function showConnectionWarning() { + const warningHtml = ` +
+ ⚠️ Connection issue detected - Data may be stale +
+ `; + + if (!document.getElementById('connection-warning')) { + document.body.insertAdjacentHTML('beforeend', warningHtml); + } +} + +// Hide connection warning +function hideConnectionWarning() { + const warning = document.getElementById('connection-warning'); + if (warning) { + warning.remove(); + } +} + +// Show notification +function showNotification(title, message, type = 'info') { + const colors = { + success: '#4CAF50', + warning: '#ff9800', + error: '#f44336', + info: '#2196F3' + }; + + const notificationHtml = ` +
+
${escapeHtml(title)}
+
${escapeHtml(message)}
+
+ + `; + + const notification = document.createElement('div'); + notification.innerHTML = notificationHtml; + document.body.appendChild(notification.firstElementChild); + + // Auto-dismiss after 5 seconds + setTimeout(() => { + const el = document.querySelector('.notification'); + if (el) { + el.style.animation = 'slideIn 0.3s ease-out reverse'; + setTimeout(() => el.remove(), 300); + } + }, 5000); +} + +// Auto-refresh +function startRefreshIntervals() { + // Update relative times every second + state.timeUpdateInterval = setInterval(updateRelativeTimes, 1000); + + // Update data timer every second + state.refreshIntervals.push( + setInterval(updateDataTimer, 1000) + ); + + // Update node online/offline badges every second + state.refreshIntervals.push( + setInterval(updateNodeBadges, 1000) + ); + + // Check connection health every 5 seconds + state.refreshIntervals.push( + setInterval(checkConnectionHealth, 5000) + ); + + // Update map marker opacity every 5 seconds + state.refreshIntervals.push( + setInterval(updateMapMarkerOpacity, 5000) + ); + + // Refresh current tab data every 3 seconds (faster for live feel) + state.refreshIntervals.push( + setInterval(() => { + const activeTab = document.querySelector('.nav-tab.active')?.getAttribute('data-tab'); + if (activeTab === 'overview') loadOverview(); + if (activeTab === 'messages') loadMessages(); + if (activeTab === 'nodes') loadNodes(); + if (activeTab === 'map') loadMap(); + }, 3000) + ); + + // Refresh MQTT status every 3 seconds + state.refreshIntervals.push( + setInterval(updateMqttStatus, 3000) + ); +} + +function stopRefreshIntervals() { + if (state.timeUpdateInterval) { + clearInterval(state.timeUpdateInterval); + state.timeUpdateInterval = null; + } + state.refreshIntervals.forEach(interval => clearInterval(interval)); + state.refreshIntervals = []; +} + +// Utility function to escape HTML +function escapeHtml(text) { + if (text === null || text === undefined) return ''; + const div = document.createElement('div'); + div.textContent = String(text); + return div.innerHTML; +} + +// Check authentication on page load +(async function() { + try { + const authStatus = await api.checkAuth(); + if (authStatus.authenticated) { + state.authenticated = true; + state.user = authStatus.user; + initDashboard(); + } else { + ui.showScreen('login-screen'); + } + } catch (error) { + ui.showScreen('login-screen'); + } +})(); diff --git a/src/auth/auth.js b/src/auth/auth.js new file mode 100644 index 0000000..a2136db --- /dev/null +++ b/src/auth/auth.js @@ -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 +}; diff --git a/src/config/config.js b/src/config/config.js new file mode 100644 index 0000000..c493685 --- /dev/null +++ b/src/config/config.js @@ -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' + } +}; diff --git a/src/database/db.js b/src/database/db.js new file mode 100644 index 0000000..31995be --- /dev/null +++ b/src/database/db.js @@ -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; diff --git a/src/database/queries.js b/src/database/queries.js new file mode 100644 index 0000000..2d62a04 --- /dev/null +++ b/src/database/queries.js @@ -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 +}; diff --git a/src/mqtt/client.js b/src/mqtt/client.js new file mode 100644 index 0000000..5a14a97 --- /dev/null +++ b/src/mqtt/client.js @@ -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; diff --git a/src/routes/api.js b/src/routes/api.js new file mode 100644 index 0000000..2331a7c --- /dev/null +++ b/src/routes/api.js @@ -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; diff --git a/src/scripts/createUser.js b/src/scripts/createUser.js new file mode 100644 index 0000000..8212863 --- /dev/null +++ b/src/scripts/createUser.js @@ -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(); diff --git a/src/server.js b/src/server.js new file mode 100644 index 0000000..23374e7 --- /dev/null +++ b/src/server.js @@ -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; diff --git a/src/services/cron.js b/src/services/cron.js new file mode 100644 index 0000000..d03ee8a --- /dev/null +++ b/src/services/cron.js @@ -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(); diff --git a/src/utils/logger.js b/src/utils/logger.js new file mode 100644 index 0000000..a780057 --- /dev/null +++ b/src/utils/logger.js @@ -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; diff --git a/test-json.js b/test-json.js new file mode 100644 index 0000000..6a64d10 --- /dev/null +++ b/test-json.js @@ -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); diff --git a/test-nodeinfo.js b/test-nodeinfo.js new file mode 100644 index 0000000..84a6fbf --- /dev/null +++ b/test-nodeinfo.js @@ -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); diff --git a/test-setup.js b/test-setup.js new file mode 100644 index 0000000..e338eff --- /dev/null +++ b/test-setup.js @@ -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();