Initial commit
This commit is contained in:
14
.env.docker
Normal file
14
.env.docker
Normal file
@@ -0,0 +1,14 @@
|
||||
# Docker Environment Configuration
|
||||
# Copy this file to .env and update with your actual values
|
||||
|
||||
# JWT Configuration
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-in-production-make-it-long-and-random
|
||||
|
||||
# Twilio Configuration (for SMS alerts)
|
||||
TWILIO_ACCOUNT_SID=your_twilio_account_sid_here
|
||||
TWILIO_AUTH_TOKEN=your_twilio_auth_token_here
|
||||
TWILIO_PHONE_NUMBER=your_twilio_phone_number_here
|
||||
|
||||
# Optional: Override default settings
|
||||
# NODE_ENV=production
|
||||
# CORS_ORIGIN=http://localhost:3000
|
||||
18
.github/copilot-instructions.md
vendored
Normal file
18
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
<!-- Use this file to provide workspace-specific custom instructions to Copilot. For more details, visit https://code.visualstudio.com/docs/copilot/copilot-customization#_use-a-githubcopilotinstructionsmd-file -->
|
||||
- [x] Verify that the copilot-instructions.md file in the .github directory is created. ✓
|
||||
|
||||
- [ ] Clarify Project Requirements
|
||||
|
||||
- [ ] Scaffold the Project
|
||||
|
||||
- [ ] Customize the Project
|
||||
|
||||
- [ ] Install Required Extensions
|
||||
|
||||
- [ ] Compile the Project
|
||||
|
||||
- [ ] Create and Run Task
|
||||
|
||||
- [ ] Launch the Project
|
||||
|
||||
- [ ] Ensure Documentation is Complete
|
||||
403
README.md
Normal file
403
README.md
Normal file
@@ -0,0 +1,403 @@
|
||||
# Drone Detection System
|
||||
|
||||
A comprehensive real-time drone detection and monitoring system with SMS alerts, real-time mapping, and advanced analytics.
|
||||
|
||||
## Features
|
||||
|
||||
### Core Functionality
|
||||
- **Real-time Drone Detection**: Receive and process drone detection data from hardware sensors
|
||||
- **Intelligent Threat Assessment**: RSSI-based threat classification (Critical, High, Medium, Low, Monitoring)
|
||||
- **Device Management**: Monitor and manage drone detection devices with heartbeat monitoring
|
||||
- **Real-time Mapping**: Interactive map showing device locations and detection status with threat indicators
|
||||
- **SMS Alerts**: Configurable SMS notifications via Twilio with threat-based escalation
|
||||
- **Analytics Dashboard**: Real-time charts and statistics with threat level breakdowns
|
||||
- **Alert Rules**: Flexible alert configuration with threat level thresholds and distance limits
|
||||
|
||||
### Hardware Integration
|
||||
- **Detection Data Processing**: Handles JSON data from drone detection hardware
|
||||
- **Heartbeat Monitoring**: Tracks device health and connectivity status
|
||||
- **Device Status Tracking**: Real-time monitoring of device online/offline status
|
||||
|
||||
### Advanced Features
|
||||
- **Multi-user Support**: Role-based access control (admin, operator, viewer)
|
||||
- **Real-time Updates**: WebSocket-based live updates across all clients
|
||||
- **Intelligent Threat Assessment**: RSSI-based distance calculation and threat classification
|
||||
- **Security-Focused Alerts**: Enhanced alert messages with threat descriptions and action requirements
|
||||
- **Swedish Location Support**: Pre-configured for government sites, water facilities, and sensitive areas
|
||||
- **Alert Management**: Comprehensive alert rule configuration with threat level thresholds
|
||||
- **Historical Data**: Complete detection history with threat level filtering and search
|
||||
- **API-first Design**: RESTful API for easy integration with external systems
|
||||
|
||||
## Technology Stack
|
||||
|
||||
### Backend
|
||||
- **Node.js** with Express.js framework
|
||||
- **PostgreSQL** database with Sequelize ORM
|
||||
- **Socket.IO** for real-time communication
|
||||
- **Twilio** for SMS alerts
|
||||
- **JWT** authentication
|
||||
- **Rate limiting** and security middleware
|
||||
|
||||
### Frontend
|
||||
- **React 18** with modern hooks
|
||||
- **React Leaflet** for interactive maps
|
||||
- **Tailwind CSS** for styling
|
||||
- **Recharts** for data visualization
|
||||
- **Framer Motion** for animations
|
||||
- **React Hot Toast** for notifications
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
uamils/
|
||||
├── server/ # Backend API
|
||||
│ ├── models/ # Database models
|
||||
│ ├── routes/ # API routes
|
||||
│ ├── services/ # Business logic
|
||||
│ ├── middleware/ # Express middleware
|
||||
│ └── scripts/ # Database setup scripts
|
||||
├── client/ # Frontend React app
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable components
|
||||
│ │ ├── pages/ # Page components
|
||||
│ │ ├── contexts/ # React contexts
|
||||
│ │ └── services/ # API services
|
||||
└── docs/ # Documentation
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 16+ and npm
|
||||
- PostgreSQL 12+
|
||||
- Twilio account (for SMS alerts)
|
||||
|
||||
### Native Installation
|
||||
|
||||
1. **Clone and install dependencies**:
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd uamils
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
2. **Database Setup**:
|
||||
```bash
|
||||
# Create PostgreSQL database
|
||||
createdb drone_detection
|
||||
|
||||
# Copy environment file
|
||||
cd server
|
||||
cp .env.example .env
|
||||
|
||||
# Edit .env with your database credentials and Twilio config
|
||||
# Then run database setup
|
||||
npm run db:setup
|
||||
```
|
||||
|
||||
3. **Start Development Servers**:
|
||||
```bash
|
||||
# From project root - starts both backend and frontend
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or start separately:
|
||||
```bash
|
||||
# Backend (port 3001)
|
||||
npm run server:dev
|
||||
|
||||
# Frontend (port 3000)
|
||||
npm run client:dev
|
||||
```
|
||||
|
||||
4. **Access the application**:
|
||||
- Frontend: http://localhost:3000
|
||||
- Backend API: http://localhost:3001/api
|
||||
|
||||
### Docker Installation (Recommended)
|
||||
|
||||
For the easiest setup, use Docker:
|
||||
|
||||
```bash
|
||||
# Prerequisites: Docker and Docker Compose
|
||||
|
||||
# 1. Copy environment template
|
||||
cp .env.docker .env
|
||||
|
||||
# 2. Edit .env with your Twilio credentials
|
||||
# TWILIO_ACCOUNT_SID=your_sid
|
||||
# TWILIO_AUTH_TOKEN=your_token
|
||||
# TWILIO_PHONE_NUMBER=your_phone
|
||||
|
||||
# 3. Start the system
|
||||
docker-compose up -d
|
||||
|
||||
# 4. Access the application
|
||||
# Frontend: http://localhost:3000
|
||||
# Backend: http://localhost:3001/api
|
||||
|
||||
# Quick start script (Windows/Linux)
|
||||
./docker-start.sh # Linux/Mac
|
||||
docker-start.bat # Windows
|
||||
```
|
||||
|
||||
**Docker Profiles:**
|
||||
- **Default**: `docker-compose up -d` (Frontend + Backend + Database + Redis)
|
||||
- **Production**: `docker-compose --profile production up -d` (+ Nginx proxy)
|
||||
- **Simulation**: `docker-compose --profile simulation up -d` (+ Python simulator)
|
||||
|
||||
For detailed Docker deployment guide, see [docs/DOCKER_DEPLOYMENT.md](docs/DOCKER_DEPLOYMENT.md).
|
||||
|
||||
## Testing with Swedish Drone Simulator
|
||||
|
||||
The system includes a comprehensive Python simulation script for testing with realistic Swedish coordinates:
|
||||
|
||||
```bash
|
||||
# Install Python dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run basic simulation (5 devices at Swedish government sites)
|
||||
python drone_simulator.py
|
||||
|
||||
# Custom simulation with 10 devices for 2 hours
|
||||
python drone_simulator.py --devices 10 --duration 7200
|
||||
|
||||
# Show available Swedish locations
|
||||
python drone_simulator.py --list-locations
|
||||
```
|
||||
|
||||
The simulator generates:
|
||||
- **Realistic RSSI values** based on actual distance calculations
|
||||
- **Threat-based scenarios** with different probability weights
|
||||
- **Swedish coordinates** for government sites, water facilities, nuclear plants
|
||||
- **Continuous heartbeats** for device health monitoring
|
||||
- **Multiple threat levels** from monitoring (15km) to critical (<50m)
|
||||
|
||||
## Threat Assessment System
|
||||
|
||||
The system includes intelligent threat assessment specifically designed for Swedish government sites and sensitive facilities:
|
||||
|
||||
### Threat Levels
|
||||
- **🔴 CRITICAL** (0-50m): Immediate security response required
|
||||
- **🟠 HIGH** (50-200m): Security response recommended
|
||||
- **🟡 MEDIUM** (200m-1km): Enhanced monitoring
|
||||
- **🟢 LOW** (1-5km): Standard monitoring
|
||||
- **⚪ MONITORING** (5-15km): Passive monitoring
|
||||
|
||||
### RSSI-Based Distance Calculation
|
||||
```
|
||||
Estimated Distance = 10^((RSSI_at_1m - RSSI) / (10 * path_loss_exponent))
|
||||
```
|
||||
|
||||
### Enhanced Alert Messages
|
||||
```
|
||||
🚨 SECURITY ALERT 🚨
|
||||
THREAT LEVEL: HIGH
|
||||
HIGH THREAT: Drone approaching facility (50-200m)
|
||||
|
||||
📍 LOCATION: Riksdag Stockholm
|
||||
📏 DISTANCE: ~150m
|
||||
📶 SIGNAL: -45 dBm
|
||||
🚁 DRONE TYPE: Professional/Military
|
||||
⚠️ IMMEDIATE ACTION REQUIRED
|
||||
```
|
||||
|
||||
For detailed threat assessment documentation, see [docs/THREAT_ASSESSMENT.md](docs/THREAT_ASSESSMENT.md).
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables (.env)
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=drone_detection
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your-super-secret-jwt-key
|
||||
|
||||
# Twilio (for SMS alerts)
|
||||
TWILIO_ACCOUNT_SID=your_twilio_sid
|
||||
TWILIO_AUTH_TOKEN=your_twilio_token
|
||||
TWILIO_PHONE_NUMBER=your_twilio_phone
|
||||
|
||||
# API Settings
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Drone Detection Endpoint
|
||||
```http
|
||||
POST /api/detections
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"device_id": 1941875381,
|
||||
"geo_lat": 59.3293,
|
||||
"geo_lon": 18.0686,
|
||||
"device_timestamp": 1691755018,
|
||||
"drone_type": 0,
|
||||
"rssi": -45,
|
||||
"freq": 20,
|
||||
"drone_id": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Heartbeat Endpoint
|
||||
```http
|
||||
POST /api/heartbeat
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": "heartbeat",
|
||||
"key": "device_1941875381_key",
|
||||
"battery_level": 85,
|
||||
"signal_strength": -50,
|
||||
"temperature": 22.5
|
||||
}
|
||||
```
|
||||
|
||||
### Device Management
|
||||
- `GET /api/devices` - List all devices
|
||||
- `GET /api/devices/map` - Get devices with location data
|
||||
- `POST /api/devices` - Create new device (admin)
|
||||
- `PUT /api/devices/:id` - Update device (admin)
|
||||
|
||||
### User Authentication
|
||||
- `POST /api/users/login` - User login
|
||||
- `POST /api/users/register` - Register new user
|
||||
- `GET /api/users/profile` - Get current user profile
|
||||
|
||||
### Alert Management
|
||||
- `GET /api/alerts/rules` - Get user's alert rules
|
||||
- `POST /api/alerts/rules` - Create new alert rule
|
||||
- `GET /api/alerts/logs` - Get alert history
|
||||
|
||||
## Hardware Integration
|
||||
|
||||
### Expected Data Format
|
||||
|
||||
The system expects drone detection data in this format:
|
||||
```json
|
||||
{
|
||||
"device_id": 1941875381,
|
||||
"geo_lat": 0,
|
||||
"geo_lon": 0,
|
||||
"device_timestamp": 0,
|
||||
"drone_type": 0,
|
||||
"rssi": 0,
|
||||
"freq": 20,
|
||||
"drone_id": 2
|
||||
}
|
||||
```
|
||||
|
||||
### Field Descriptions
|
||||
- `device_id`: Unique identifier for the detection device
|
||||
- `geo_lat`/`geo_lon`: GPS coordinates of the device
|
||||
- `device_timestamp`: Unix timestamp from the device
|
||||
- `drone_type`: Type classification of detected drone
|
||||
- `rssi`: Received Signal Strength Indicator
|
||||
- `freq`: Frequency of detected signal
|
||||
- `drone_id`: Unique identifier for the detected drone
|
||||
|
||||
## Recommended Additional Features
|
||||
|
||||
Based on the requirements, here are additional features that would enhance the system:
|
||||
|
||||
### 1. **Analytics & Reporting**
|
||||
- Historical trend analysis
|
||||
- Drone pattern recognition
|
||||
- Frequency analysis and spectrum monitoring
|
||||
- Device performance metrics
|
||||
- Custom report generation
|
||||
|
||||
### 2. **Advanced Alerting**
|
||||
- Email notifications
|
||||
- Webhook integrations for external systems
|
||||
- Escalation procedures
|
||||
- Alert scheduling (business hours only)
|
||||
- Custom alert templates
|
||||
|
||||
### 3. **Security & Compliance**
|
||||
- Audit logging
|
||||
- Data encryption at rest
|
||||
- API rate limiting per client
|
||||
- GDPR compliance features
|
||||
- Role-based permissions
|
||||
|
||||
### 4. **Integration Features**
|
||||
- REST API for third-party integrations
|
||||
- Webhook support for external systems
|
||||
- MQTT support for IoT devices
|
||||
- Integration with security systems
|
||||
- Export capabilities (CSV, PDF)
|
||||
|
||||
### 5. **Advanced Mapping**
|
||||
- Heatmaps of detection frequency
|
||||
- Flight path tracking
|
||||
- Geofencing capabilities
|
||||
- Multiple map layers
|
||||
- Offline map support
|
||||
|
||||
### 6. **Mobile Application**
|
||||
- React Native mobile app
|
||||
- Push notifications
|
||||
- Offline viewing capabilities
|
||||
- Quick alert acknowledgment
|
||||
|
||||
### 7. **AI/ML Enhancements**
|
||||
- Drone behavior prediction
|
||||
- False positive reduction
|
||||
- Automatic drone classification
|
||||
- Anomaly detection
|
||||
- Pattern recognition
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Docker Deployment
|
||||
```bash
|
||||
# Build and run with Docker Compose
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### Manual Deployment
|
||||
1. Set up PostgreSQL database
|
||||
2. Configure environment variables
|
||||
3. Build frontend: `npm run client:build`
|
||||
4. Start backend: `npm run server:start`
|
||||
5. Serve frontend with nginx or similar
|
||||
|
||||
### Recommended Infrastructure
|
||||
- **Database**: PostgreSQL with read replicas
|
||||
- **Caching**: Redis for session management
|
||||
- **Load Balancer**: nginx or AWS ALB
|
||||
- **Monitoring**: Prometheus + Grafana
|
||||
- **Logging**: ELK stack or similar
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make changes with tests
|
||||
4. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
[Add your license here]
|
||||
|
||||
## Support
|
||||
|
||||
For support and questions:
|
||||
- Create an issue in the repository
|
||||
- Contact the development team
|
||||
- Check the documentation in `/docs`
|
||||
|
||||
---
|
||||
|
||||
**Note**: This system is designed for security and monitoring purposes. Ensure compliance with local regulations regarding drone detection and monitoring.
|
||||
210
SETUP.md
Normal file
210
SETUP.md
Normal file
@@ -0,0 +1,210 @@
|
||||
# Quick Setup Guide
|
||||
|
||||
This guide will help you get the Drone Detection System up and running quickly.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Node.js 16+** and npm
|
||||
2. **PostgreSQL 12+**
|
||||
3. **Twilio Account** (for SMS alerts)
|
||||
|
||||
## Step-by-Step Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
# Install all dependencies (root, server, and client)
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
### 2. Database Setup
|
||||
|
||||
#### Option A: Automatic Setup (Recommended)
|
||||
```bash
|
||||
# Make sure PostgreSQL is running
|
||||
# Create database
|
||||
createdb drone_detection
|
||||
|
||||
# Copy environment file and configure
|
||||
cd server
|
||||
cp .env.example .env
|
||||
# Edit .env with your database credentials and Twilio settings
|
||||
|
||||
# Run automated setup
|
||||
cd ..
|
||||
npm run db:setup
|
||||
```
|
||||
|
||||
#### Option B: Manual Setup
|
||||
```bash
|
||||
# Create PostgreSQL database
|
||||
createdb drone_detection
|
||||
|
||||
# Configure environment
|
||||
cd server
|
||||
cp .env.example .env
|
||||
# Edit the .env file with your settings
|
||||
|
||||
# Run database setup script
|
||||
node scripts/setup-database.js
|
||||
```
|
||||
|
||||
### 3. Environment Configuration
|
||||
|
||||
Edit `server/.env` with your settings:
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=drone_detection
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=your_password
|
||||
|
||||
# JWT Secret (generate a random string)
|
||||
JWT_SECRET=your-super-secret-jwt-key
|
||||
|
||||
# Twilio (get from https://console.twilio.com/)
|
||||
TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxxxxxxxxxx
|
||||
TWILIO_AUTH_TOKEN=your_auth_token
|
||||
TWILIO_PHONE_NUMBER=+1234567890
|
||||
|
||||
# Server
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
```
|
||||
|
||||
### 4. Start the Application
|
||||
|
||||
```bash
|
||||
# Start both backend and frontend in development mode
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Or start separately:
|
||||
```bash
|
||||
# Start backend (port 3001)
|
||||
npm run server:dev
|
||||
|
||||
# Start frontend (port 3000)
|
||||
npm run client:dev
|
||||
```
|
||||
|
||||
### 5. Access the Application
|
||||
|
||||
- **Frontend**: http://localhost:3000
|
||||
- **Backend API**: http://localhost:3001/api
|
||||
|
||||
### 6. Default Login Credentials
|
||||
|
||||
- **Admin**: `admin` / `admin123`
|
||||
- **Operator**: `operator` / `operator123`
|
||||
|
||||
## Testing the System
|
||||
|
||||
### 1. Test Drone Detection API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/detections \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"device_id": 1941875381,
|
||||
"geo_lat": 59.3293,
|
||||
"geo_lon": 18.0686,
|
||||
"device_timestamp": 1691755018,
|
||||
"drone_type": 0,
|
||||
"rssi": -45,
|
||||
"freq": 20,
|
||||
"drone_id": 2
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. Test Heartbeat API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/heartbeat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"type": "heartbeat",
|
||||
"key": "device_1941875381_key",
|
||||
"battery_level": 85,
|
||||
"signal_strength": -50,
|
||||
"temperature": 22.5
|
||||
}'
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
### Database Connection Issues
|
||||
- Ensure PostgreSQL is running
|
||||
- Check database credentials in `.env`
|
||||
- Verify database exists: `psql -l`
|
||||
|
||||
### Port Already in Use
|
||||
```bash
|
||||
# Find process using port 3001
|
||||
netstat -ano | findstr :3001
|
||||
# Kill process (Windows)
|
||||
taskkill /PID <PID> /F
|
||||
```
|
||||
|
||||
### Missing Dependencies
|
||||
```bash
|
||||
# Reinstall all dependencies
|
||||
npm run install:all
|
||||
```
|
||||
|
||||
### Twilio SMS Not Working
|
||||
- Verify Twilio credentials in `.env`
|
||||
- Check phone number format (+1234567890)
|
||||
- Ensure Twilio account has sufficient credits
|
||||
|
||||
## Docker Setup (Alternative)
|
||||
|
||||
If you prefer Docker:
|
||||
|
||||
```bash
|
||||
# Copy environment file
|
||||
cp server/.env.example server/.env
|
||||
# Edit server/.env with your settings
|
||||
|
||||
# Build and start with Docker
|
||||
npm run docker:up
|
||||
|
||||
# Stop services
|
||||
npm run docker:down
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
For production deployment:
|
||||
|
||||
1. Set `NODE_ENV=production` in server/.env
|
||||
2. Build frontend: `npm run client:build`
|
||||
3. Use a reverse proxy (nginx) for the frontend
|
||||
4. Use PM2 or similar for process management
|
||||
5. Set up SSL certificates
|
||||
6. Configure proper database backup
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Configure Alert Rules**: Log in and set up SMS alert rules
|
||||
2. **Add Devices**: Register your drone detection hardware
|
||||
3. **Test Real-time Features**: Check the dashboard for live updates
|
||||
4. **Customize Settings**: Modify alert conditions and user roles
|
||||
|
||||
## Getting Help
|
||||
|
||||
- Check the main README.md for detailed documentation
|
||||
- Review the API endpoints in the backend routes
|
||||
- Check browser console for frontend issues
|
||||
- Review server logs for backend issues
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Change default passwords immediately
|
||||
- Use strong JWT secrets in production
|
||||
- Enable HTTPS in production
|
||||
- Regularly update dependencies
|
||||
- Monitor access logs
|
||||
76
client/.dockerignore
Normal file
76
client/.dockerignore
Normal file
@@ -0,0 +1,76 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Production build
|
||||
/build
|
||||
/dist
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# ESLint
|
||||
.eslintcache
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
54
client/Dockerfile
Normal file
54
client/Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# Frontend Dockerfile for Drone Detection System
|
||||
# Multi-stage build for optimized production image
|
||||
|
||||
# Build stage
|
||||
FROM node:18-alpine AS builder
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies (including dev dependencies for build)
|
||||
RUN npm ci
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG VITE_API_URL=http://localhost:3001/api
|
||||
ARG VITE_WS_URL=ws://localhost:3001
|
||||
|
||||
# Set environment variables for build
|
||||
ENV VITE_API_URL=$VITE_API_URL
|
||||
ENV VITE_WS_URL=$VITE_WS_URL
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Install curl for health checks
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
# Copy built application from builder stage
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Copy custom nginx configuration
|
||||
COPY docker/nginx/default.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Create nginx user and set permissions
|
||||
RUN chown -R nginx:nginx /usr/share/nginx/html && \
|
||||
chmod -R 755 /usr/share/nginx/html
|
||||
|
||||
# Expose port 80
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
|
||||
CMD curl -f http://localhost:80 || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Drone Detection System</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
client/package.json
Normal file
50
client/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "drone-detection-client",
|
||||
"version": "1.0.0",
|
||||
"description": "Frontend for drone detection system",
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"axios": "^1.5.0",
|
||||
"recharts": "^2.8.0",
|
||||
"@headlessui/react": "^1.7.17",
|
||||
"@heroicons/react": "^2.0.18",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"date-fns": "^2.30.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"framer-motion": "^10.16.4",
|
||||
"classnames": "^2.3.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.0.3",
|
||||
"vite": "^4.4.5",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"postcss": "^8.4.29",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"@types/leaflet": "^1.9.4"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
">0.2%",
|
||||
"not dead",
|
||||
"not op_mini all"
|
||||
],
|
||||
"development": [
|
||||
"last 1 chrome version",
|
||||
"last 1 firefox version",
|
||||
"last 1 safari version"
|
||||
]
|
||||
}
|
||||
}
|
||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
68
client/src/App.jsx
Normal file
68
client/src/App.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { SocketProvider } from './contexts/SocketContext';
|
||||
import Layout from './components/Layout';
|
||||
import Dashboard from './pages/Dashboard';
|
||||
import MapView from './pages/MapView';
|
||||
import Devices from './pages/Devices';
|
||||
import Detections from './pages/Detections';
|
||||
import Alerts from './pages/Alerts';
|
||||
import Login from './pages/Login';
|
||||
import ProtectedRoute from './components/ProtectedRoute';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SocketProvider>
|
||||
<Router>
|
||||
<div className="App">
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: '#363636',
|
||||
color: '#fff',
|
||||
},
|
||||
success: {
|
||||
duration: 3000,
|
||||
iconTheme: {
|
||||
primary: '#4ade80',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
duration: 5000,
|
||||
iconTheme: {
|
||||
primary: '#ef4444',
|
||||
secondary: '#fff',
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute>
|
||||
<Layout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="map" element={<MapView />} />
|
||||
<Route path="devices" element={<Devices />} />
|
||||
<Route path="detections" element={<Detections />} />
|
||||
<Route path="alerts" element={<Alerts />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</div>
|
||||
</Router>
|
||||
</SocketProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
190
client/src/components/Layout.jsx
Normal file
190
client/src/components/Layout.jsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import {
|
||||
HomeIcon,
|
||||
MapIcon,
|
||||
ServerIcon,
|
||||
ExclamationTriangleIcon,
|
||||
BellIcon,
|
||||
UserIcon,
|
||||
Bars3Icon,
|
||||
XMarkIcon,
|
||||
SignalIcon,
|
||||
WifiIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: HomeIcon },
|
||||
{ name: 'Map View', href: '/map', icon: MapIcon },
|
||||
{ name: 'Devices', href: '/devices', icon: ServerIcon },
|
||||
{ name: 'Detections', href: '/detections', icon: ExclamationTriangleIcon },
|
||||
{ name: 'Alerts', href: '/alerts', icon: BellIcon },
|
||||
];
|
||||
|
||||
const Layout = () => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const { user, logout } = useAuth();
|
||||
const { connected, recentDetections } = useSocket();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden bg-gray-100">
|
||||
{/* Mobile sidebar */}
|
||||
<div className={classNames(
|
||||
'fixed inset-0 flex z-40 md:hidden',
|
||||
sidebarOpen ? 'block' : 'hidden'
|
||||
)}>
|
||||
<div className="fixed inset-0 bg-gray-600 bg-opacity-75" onClick={() => setSidebarOpen(false)} />
|
||||
<div className="relative flex-1 flex flex-col max-w-xs w-full pt-5 pb-4 bg-white">
|
||||
<div className="absolute top-0 right-0 -mr-12 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 flex items-center justify-center h-10 w-10 rounded-full focus:outline-none focus:ring-2 focus:ring-inset focus:ring-white"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6 text-white" />
|
||||
</button>
|
||||
</div>
|
||||
<SidebarContent />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Static sidebar for desktop */}
|
||||
<div className="hidden md:flex md:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col flex-grow pt-5 pb-4 overflow-y-auto bg-white border-r border-gray-200">
|
||||
<SidebarContent />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="relative z-10 flex-shrink-0 flex h-16 bg-white border-b border-gray-200">
|
||||
<button
|
||||
type="button"
|
||||
className="px-4 border-r border-gray-200 text-gray-400 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-primary-500 md:hidden"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<Bars3Icon className="h-6 w-6" />
|
||||
</button>
|
||||
|
||||
{/* Top navigation */}
|
||||
<div className="flex-1 px-4 flex justify-between items-center">
|
||||
<div className="flex-1 flex">
|
||||
<h1 className="text-xl font-semibold text-gray-900">
|
||||
{navigation.find(item => item.href === location.pathname)?.name || 'Drone Detection System'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="ml-4 flex items-center md:ml-6 space-x-4">
|
||||
{/* Connection status */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={classNames(
|
||||
'flex items-center space-x-1 px-2 py-1 rounded-full text-xs font-medium',
|
||||
connected
|
||||
? 'bg-success-100 text-success-800'
|
||||
: 'bg-danger-100 text-danger-800'
|
||||
)}>
|
||||
{connected ? (
|
||||
<WifiIcon className="h-3 w-3" />
|
||||
) : (
|
||||
<SignalIcon className="h-3 w-3" />
|
||||
)}
|
||||
<span>{connected ? 'Connected' : 'Disconnected'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent detections count */}
|
||||
{recentDetections.length > 0 && (
|
||||
<div className="flex items-center space-x-1 px-2 py-1 bg-danger-100 text-danger-800 rounded-full text-xs font-medium">
|
||||
<ExclamationTriangleIcon className="h-3 w-3" />
|
||||
<span>{recentDetections.length} recent</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User menu */}
|
||||
<div className="ml-3 relative">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-1 text-sm text-gray-700">
|
||||
<UserIcon className="h-4 w-4" />
|
||||
<span>{user?.username}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="text-sm text-gray-500 hover:text-gray-700"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Page content */}
|
||||
<main className="flex-1 relative overflow-y-auto focus:outline-none">
|
||||
<div className="py-6">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 md:px-8">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SidebarContent = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center flex-shrink-0 px-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-8 h-8 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold text-gray-900">
|
||||
Drone Detector
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex-grow flex flex-col">
|
||||
<nav className="flex-1 px-2 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = location.pathname === item.href;
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
to={item.href}
|
||||
className={classNames(
|
||||
isActive
|
||||
? 'bg-primary-100 border-primary-500 text-primary-700'
|
||||
: 'border-transparent text-gray-600 hover:bg-gray-50 hover:text-gray-900',
|
||||
'group flex items-center px-2 py-2 text-sm font-medium border-l-4 transition-colors duration-200'
|
||||
)}
|
||||
>
|
||||
<item.icon
|
||||
className={classNames(
|
||||
isActive
|
||||
? 'text-primary-500'
|
||||
: 'text-gray-400 group-hover:text-gray-500',
|
||||
'mr-3 h-5 w-5'
|
||||
)}
|
||||
/>
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Layout;
|
||||
23
client/src/components/ProtectedRoute.jsx
Normal file
23
client/src/components/ProtectedRoute.jsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const ProtectedRoute = ({ children }) => {
|
||||
const { isAuthenticated, loading } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export default ProtectedRoute;
|
||||
136
client/src/contexts/AuthContext.jsx
Normal file
136
client/src/contexts/AuthContext.jsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import React, { createContext, useContext, useReducer, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
const authReducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case 'LOGIN_START':
|
||||
return { ...state, loading: true, error: null };
|
||||
case 'LOGIN_SUCCESS':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
user: action.payload.user,
|
||||
token: action.payload.token,
|
||||
isAuthenticated: true
|
||||
};
|
||||
case 'LOGIN_FAILURE':
|
||||
return {
|
||||
...state,
|
||||
loading: false,
|
||||
error: action.payload,
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false
|
||||
};
|
||||
case 'LOGOUT':
|
||||
return {
|
||||
...state,
|
||||
user: null,
|
||||
token: null,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
error: null
|
||||
};
|
||||
case 'SET_LOADING':
|
||||
return { ...state, loading: action.payload };
|
||||
case 'SET_ERROR':
|
||||
return { ...state, error: action.payload };
|
||||
case 'UPDATE_USER':
|
||||
return { ...state, user: { ...state.user, ...action.payload } };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
const initialState = {
|
||||
user: null,
|
||||
token: localStorage.getItem('token'),
|
||||
isAuthenticated: false,
|
||||
loading: true,
|
||||
error: null
|
||||
};
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [state, dispatch] = useReducer(authReducer, initialState);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user is logged in on app start
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
// Validate token and get user info
|
||||
checkAuthStatus();
|
||||
} else {
|
||||
dispatch({ type: 'SET_LOADING', payload: false });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAuthStatus = async () => {
|
||||
try {
|
||||
const response = await api.get('/users/profile');
|
||||
dispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
payload: {
|
||||
user: response.data.data,
|
||||
token: localStorage.getItem('token')
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (credentials) => {
|
||||
try {
|
||||
dispatch({ type: 'LOGIN_START' });
|
||||
const response = await api.post('/users/login', credentials);
|
||||
|
||||
const { user, token } = response.data.data;
|
||||
localStorage.setItem('token', token);
|
||||
|
||||
dispatch({
|
||||
type: 'LOGIN_SUCCESS',
|
||||
payload: { user, token }
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || 'Login failed';
|
||||
dispatch({ type: 'LOGIN_FAILURE', payload: errorMessage });
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('token');
|
||||
dispatch({ type: 'LOGOUT' });
|
||||
};
|
||||
|
||||
const updateUser = (userData) => {
|
||||
dispatch({ type: 'UPDATE_USER', payload: userData });
|
||||
};
|
||||
|
||||
const value = {
|
||||
...state,
|
||||
login,
|
||||
logout,
|
||||
updateUser,
|
||||
checkAuthStatus
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
134
client/src/contexts/SocketContext.jsx
Normal file
134
client/src/contexts/SocketContext.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { io } from 'socket.io-client';
|
||||
import { useAuth } from './AuthContext';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const SocketContext = createContext();
|
||||
|
||||
export const SocketProvider = ({ children }) => {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [recentDetections, setRecentDetections] = useState([]);
|
||||
const [deviceStatus, setDeviceStatus] = useState({});
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
// Initialize socket connection
|
||||
const newSocket = io(process.env.NODE_ENV === 'production'
|
||||
? window.location.origin
|
||||
: 'http://localhost:3001'
|
||||
);
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('Connected to server');
|
||||
setConnected(true);
|
||||
|
||||
// Join dashboard room for general updates
|
||||
newSocket.emit('join_dashboard');
|
||||
|
||||
toast.success('Connected to real-time updates');
|
||||
});
|
||||
|
||||
newSocket.on('disconnect', () => {
|
||||
console.log('Disconnected from server');
|
||||
setConnected(false);
|
||||
toast.error('Disconnected from server');
|
||||
});
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
console.error('Connection error:', error);
|
||||
setConnected(false);
|
||||
toast.error('Failed to connect to server');
|
||||
});
|
||||
|
||||
// Listen for drone detections
|
||||
newSocket.on('drone_detection', (detection) => {
|
||||
console.log('New drone detection:', detection);
|
||||
|
||||
setRecentDetections(prev => [detection, ...prev.slice(0, 49)]); // Keep last 50
|
||||
|
||||
// Show toast notification
|
||||
toast.error(
|
||||
`Drone detected by ${detection.device.name || `Device ${detection.device_id}`}`,
|
||||
{
|
||||
duration: 5000,
|
||||
icon: '🚨',
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
// Listen for device heartbeats
|
||||
newSocket.on('device_heartbeat', (heartbeat) => {
|
||||
console.log('Device heartbeat:', heartbeat);
|
||||
|
||||
setDeviceStatus(prev => ({
|
||||
...prev,
|
||||
[heartbeat.device_id]: {
|
||||
...heartbeat,
|
||||
last_seen: new Date()
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
// Listen for device updates
|
||||
newSocket.on('device_updated', (device) => {
|
||||
console.log('Device updated:', device);
|
||||
toast.success(`Device ${device.name || device.id} updated`);
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
return () => {
|
||||
newSocket.disconnect();
|
||||
};
|
||||
} else {
|
||||
// Disconnect if not authenticated
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setSocket(null);
|
||||
setConnected(false);
|
||||
}
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const joinDeviceRoom = (deviceId) => {
|
||||
if (socket) {
|
||||
socket.emit('join_device_room', deviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const leaveDeviceRoom = (deviceId) => {
|
||||
if (socket) {
|
||||
socket.emit('leave_device_room', deviceId);
|
||||
}
|
||||
};
|
||||
|
||||
const clearRecentDetections = () => {
|
||||
setRecentDetections([]);
|
||||
};
|
||||
|
||||
const value = {
|
||||
socket,
|
||||
connected,
|
||||
recentDetections,
|
||||
deviceStatus,
|
||||
joinDeviceRoom,
|
||||
leaveDeviceRoom,
|
||||
clearRecentDetections
|
||||
};
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={value}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useSocket = () => {
|
||||
const context = useContext(SocketContext);
|
||||
if (!context) {
|
||||
throw new Error('useSocket must be used within a SocketProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
103
client/src/index.css
Normal file
103
client/src/index.css
Normal file
@@ -0,0 +1,103 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Custom styles */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Leaflet map fixes */
|
||||
.leaflet-container {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Custom components */
|
||||
.card {
|
||||
@apply bg-white rounded-lg shadow-md border border-gray-200;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
@apply px-6 py-4 border-b border-gray-200;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
@apply px-6 py-4;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-md font-medium transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-success-600 text-white hover:bg-success-700 focus:ring-success-500;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-success-100 text-success-800;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply bg-danger-100 text-danger-800;
|
||||
}
|
||||
|
||||
.status-warning {
|
||||
@apply bg-warning-100 text-warning-800;
|
||||
}
|
||||
|
||||
/* Animation classes */
|
||||
.fade-in {
|
||||
@apply animate-fade-in;
|
||||
}
|
||||
|
||||
.slide-up {
|
||||
@apply animate-slide-up;
|
||||
}
|
||||
|
||||
/* Map marker pulse animation */
|
||||
.marker-pulse {
|
||||
@apply animate-pulse-slow;
|
||||
}
|
||||
|
||||
/* Responsive table */
|
||||
.table-responsive {
|
||||
@apply overflow-x-auto;
|
||||
}
|
||||
|
||||
.table {
|
||||
@apply min-w-full divide-y divide-gray-200;
|
||||
}
|
||||
|
||||
.table th {
|
||||
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.table td {
|
||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
||||
}
|
||||
10
client/src/main.jsx
Normal file
10
client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
)
|
||||
574
client/src/pages/Alerts.jsx
Normal file
574
client/src/pages/Alerts.jsx
Normal file
@@ -0,0 +1,574 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
PlusIcon,
|
||||
BellIcon,
|
||||
CheckCircleIcon,
|
||||
XCircleIcon,
|
||||
ExclamationTriangleIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const Alerts = () => {
|
||||
const [alertRules, setAlertRules] = useState([]);
|
||||
const [alertLogs, setAlertLogs] = useState([]);
|
||||
const [alertStats, setAlertStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState('rules');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAlertData();
|
||||
}, []);
|
||||
|
||||
const fetchAlertData = async () => {
|
||||
try {
|
||||
const [rulesRes, logsRes, statsRes] = await Promise.all([
|
||||
api.get('/alerts/rules'),
|
||||
api.get('/alerts/logs?limit=50'),
|
||||
api.get('/alerts/stats?hours=24')
|
||||
]);
|
||||
|
||||
setAlertRules(rulesRes.data.data);
|
||||
setAlertLogs(logsRes.data.data);
|
||||
setAlertStats(statsRes.data.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching alert data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRule = async (ruleId) => {
|
||||
if (window.confirm('Are you sure you want to delete this alert rule?')) {
|
||||
try {
|
||||
await api.delete(`/alerts/rules/${ruleId}`);
|
||||
fetchAlertData();
|
||||
} catch (error) {
|
||||
console.error('Error deleting alert rule:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status) => {
|
||||
switch (status) {
|
||||
case 'sent':
|
||||
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircleIcon className="h-5 w-5 text-red-500" />;
|
||||
case 'pending':
|
||||
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
|
||||
default:
|
||||
return <BellIcon className="h-5 w-5 text-gray-500" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPriorityColor = (priority) => {
|
||||
switch (priority) {
|
||||
case 'critical':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'high':
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'medium':
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'low':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Alert Management
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Configure and monitor alert rules for drone detections
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn btn-primary flex items-center space-x-2"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<span>Create Alert Rule</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Alert Stats */}
|
||||
{alertStats && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-gray-900">{alertStats.total_alerts}</div>
|
||||
<div className="text-sm text-gray-500">Total Alerts (24h)</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-green-600">{alertStats.sent_alerts}</div>
|
||||
<div className="text-sm text-gray-500">Sent Successfully</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-red-600">{alertStats.failed_alerts}</div>
|
||||
<div className="text-sm text-gray-500">Failed</div>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg shadow border">
|
||||
<div className="text-2xl font-bold text-yellow-600">{alertStats.pending_alerts}</div>
|
||||
<div className="text-sm text-gray-500">Pending</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-gray-200">
|
||||
<nav className="-mb-px flex space-x-8">
|
||||
<button
|
||||
onClick={() => setActiveTab('rules')}
|
||||
className={`whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'rules'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Alert Rules ({alertRules.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('logs')}
|
||||
className={`whitespace-nowrap py-2 px-1 border-b-2 font-medium text-sm ${
|
||||
activeTab === 'logs'
|
||||
? 'border-primary-500 text-primary-600'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
Alert Logs ({alertLogs.length})
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Alert Rules Tab */}
|
||||
{activeTab === 'rules' && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{alertRules.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<BellIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No alert rules</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by creating your first alert rule.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Create Alert Rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Priority</th>
|
||||
<th>Channels</th>
|
||||
<th>Conditions</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alertRules.map((rule) => (
|
||||
<tr key={rule.id} className="hover:bg-gray-50">
|
||||
<td>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{rule.name}
|
||||
</div>
|
||||
{rule.description && (
|
||||
<div className="text-sm text-gray-500">
|
||||
{rule.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
getPriorityColor(rule.priority)
|
||||
}`}>
|
||||
{rule.priority}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex space-x-1">
|
||||
{rule.alert_channels.map((channel, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs"
|
||||
>
|
||||
{channel}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900">
|
||||
{rule.min_detections > 1 && (
|
||||
<div>Min detections: {rule.min_detections}</div>
|
||||
)}
|
||||
{rule.time_window && (
|
||||
<div>Time window: {rule.time_window}s</div>
|
||||
)}
|
||||
{rule.cooldown_period && (
|
||||
<div>Cooldown: {rule.cooldown_period}s</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
rule.is_active
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{rule.is_active ? 'Active' : 'Inactive'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900">
|
||||
{format(new Date(rule.created_at), 'MMM dd, yyyy')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
// TODO: Edit rule
|
||||
console.log('Edit rule:', rule);
|
||||
}}
|
||||
className="text-primary-600 hover:text-primary-900 text-sm"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteRule(rule.id)}
|
||||
className="text-red-600 hover:text-red-900 text-sm"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Alert Logs Tab */}
|
||||
{activeTab === 'logs' && (
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{alertLogs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<BellIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No alert logs</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Alert logs will appear here when alerts are triggered.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Type</th>
|
||||
<th>Recipient</th>
|
||||
<th>Rule</th>
|
||||
<th>Message</th>
|
||||
<th>Sent At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{alertLogs.map((log) => (
|
||||
<tr key={log.id} className="hover:bg-gray-50">
|
||||
<td>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(log.status)}
|
||||
<span className="text-sm text-gray-900 capitalize">
|
||||
{log.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">
|
||||
{log.alert_type}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900">
|
||||
{log.recipient}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900">
|
||||
{log.rule?.name || 'Unknown Rule'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900 max-w-xs truncate">
|
||||
{log.message}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900">
|
||||
{log.sent_at
|
||||
? format(new Date(log.sent_at), 'MMM dd, HH:mm')
|
||||
: 'Not sent'
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Alert Rule Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateAlertRuleModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSave={() => {
|
||||
setShowCreateModal(false);
|
||||
fetchAlertData();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const CreateAlertRuleModal = ({ onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
alert_channels: ['sms'],
|
||||
min_detections: 1,
|
||||
time_window: 300,
|
||||
cooldown_period: 600,
|
||||
device_ids: null,
|
||||
drone_types: null,
|
||||
min_rssi: '',
|
||||
max_rssi: '',
|
||||
frequency_ranges: []
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const payload = { ...formData };
|
||||
|
||||
// Clean up empty values
|
||||
if (!payload.min_rssi) delete payload.min_rssi;
|
||||
if (!payload.max_rssi) delete payload.max_rssi;
|
||||
if (!payload.device_ids || payload.device_ids.length === 0) payload.device_ids = null;
|
||||
if (!payload.drone_types || payload.drone_types.length === 0) payload.drone_types = null;
|
||||
if (!payload.frequency_ranges || payload.frequency_ranges.length === 0) payload.frequency_ranges = null;
|
||||
|
||||
await api.post('/alerts/rules', payload);
|
||||
onSave();
|
||||
} catch (error) {
|
||||
console.error('Error creating alert rule:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value, type } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'number' ? parseInt(value) || 0 : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleChannelChange = (channel, checked) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
alert_channels: checked
|
||||
? [...prev.alert_channels, channel]
|
||||
: prev.alert_channels.filter(c => c !== channel)
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Create Alert Rule
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Rule Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
required
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
rows="2"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Priority
|
||||
</label>
|
||||
<select
|
||||
name="priority"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.priority}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Min Detections
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="min_detections"
|
||||
min="1"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.min_detections}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Time Window (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="time_window"
|
||||
min="60"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.time_window}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Cooldown Period (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="cooldown_period"
|
||||
min="0"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.cooldown_period}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Alert Channels
|
||||
</label>
|
||||
<div className="space-y-2">
|
||||
{['sms', 'email', 'webhook'].map(channel => (
|
||||
<label key={channel} className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.alert_channels.includes(channel)}
|
||||
onChange={(e) => handleChannelChange(channel, e.target.checked)}
|
||||
className="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-gray-700 capitalize">
|
||||
{channel}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Creating...' : 'Create Rule'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Alerts;
|
||||
325
client/src/pages/Dashboard.jsx
Normal file
325
client/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,325 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import api from '../services/api';
|
||||
import {
|
||||
ServerIcon,
|
||||
ExclamationTriangleIcon,
|
||||
BellIcon,
|
||||
SignalIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
PieChart,
|
||||
Pie,
|
||||
Cell
|
||||
} from 'recharts';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
const Dashboard = () => {
|
||||
const [overview, setOverview] = useState(null);
|
||||
const [chartData, setChartData] = useState([]);
|
||||
const [deviceActivity, setDeviceActivity] = useState([]);
|
||||
const [recentActivity, setRecentActivity] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { recentDetections, deviceStatus, connected } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboardData();
|
||||
const interval = setInterval(fetchDashboardData, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
try {
|
||||
const [overviewRes, chartRes, deviceRes, activityRes] = await Promise.all([
|
||||
api.get('/dashboard/overview?hours=24'),
|
||||
api.get('/dashboard/charts/detections?hours=24&interval=hour'),
|
||||
api.get('/dashboard/charts/devices?hours=24'),
|
||||
api.get('/dashboard/activity?hours=24&limit=10')
|
||||
]);
|
||||
|
||||
setOverview(overviewRes.data.data);
|
||||
setChartData(chartRes.data.data);
|
||||
setDeviceActivity(deviceRes.data.data);
|
||||
setRecentActivity(activityRes.data.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Total Devices',
|
||||
stat: overview?.summary?.total_devices || 0,
|
||||
icon: ServerIcon,
|
||||
change: null,
|
||||
changeType: 'neutral',
|
||||
color: 'bg-blue-500'
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Online Devices',
|
||||
stat: overview?.summary?.online_devices || 0,
|
||||
icon: SignalIcon,
|
||||
change: null,
|
||||
changeType: 'positive',
|
||||
color: 'bg-green-500'
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Recent Detections',
|
||||
stat: overview?.summary?.recent_detections || 0,
|
||||
icon: ExclamationTriangleIcon,
|
||||
change: null,
|
||||
changeType: 'negative',
|
||||
color: 'bg-red-500'
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Unique Drones',
|
||||
stat: overview?.summary?.unique_drones_detected || 0,
|
||||
icon: EyeIcon,
|
||||
change: null,
|
||||
changeType: 'neutral',
|
||||
color: 'bg-purple-500'
|
||||
}
|
||||
];
|
||||
|
||||
const deviceStatusData = [
|
||||
{ name: 'Online', value: overview?.device_status?.online || 0, color: '#22c55e' },
|
||||
{ name: 'Offline', value: overview?.device_status?.offline || 0, color: '#ef4444' },
|
||||
{ name: 'Inactive', value: overview?.device_status?.inactive || 0, color: '#6b7280' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
System Overview
|
||||
</h3>
|
||||
<dl className="mt-5 grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{stats.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="relative bg-white pt-5 px-4 pb-12 sm:pt-6 sm:px-6 shadow rounded-lg overflow-hidden"
|
||||
>
|
||||
<dt>
|
||||
<div className={`absolute ${item.color} rounded-md p-3`}>
|
||||
<item.icon className="h-6 w-6 text-white" aria-hidden="true" />
|
||||
</div>
|
||||
<p className="ml-16 text-sm font-medium text-gray-500 truncate">
|
||||
{item.name}
|
||||
</p>
|
||||
</dt>
|
||||
<dd className="ml-16 pb-6 flex items-baseline sm:pb-7">
|
||||
<p className="text-2xl font-semibold text-gray-900">
|
||||
{item.stat}
|
||||
</p>
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{/* Charts */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Detection Timeline */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Detections Timeline (24h)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="timestamp"
|
||||
tickFormatter={(value) => format(new Date(value), 'HH:mm')}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip
|
||||
labelFormatter={(value) => format(new Date(value), 'MMM dd, HH:mm')}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="#ef4444"
|
||||
fill="#ef4444"
|
||||
fillOpacity={0.3}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Status */}
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Device Status
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={deviceStatusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={80}
|
||||
paddingAngle={5}
|
||||
dataKey="value"
|
||||
>
|
||||
{deviceStatusData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-center space-x-4">
|
||||
{deviceStatusData.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
{item.name}: {item.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device Activity */}
|
||||
{deviceActivity.length > 0 && (
|
||||
<div className="bg-white p-6 rounded-lg shadow">
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Device Activity (24h)
|
||||
</h3>
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={deviceActivity}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis
|
||||
dataKey="device_name"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={60}
|
||||
/>
|
||||
<YAxis />
|
||||
<Tooltip />
|
||||
<Bar dataKey="detection_count" fill="#3b82f6" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recent Activity & Real-time Detections */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Recent Activity</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200 max-h-96 overflow-y-auto">
|
||||
{recentActivity.map((activity, index) => (
|
||||
<div key={index} className="px-6 py-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`flex-shrink-0 w-2 h-2 rounded-full ${
|
||||
activity.type === 'detection' ? 'bg-red-400' : 'bg-green-400'
|
||||
}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900">
|
||||
{activity.type === 'detection' ? (
|
||||
<>Drone {activity.data.drone_id} detected by {activity.data.device_name}</>
|
||||
) : (
|
||||
<>Heartbeat from {activity.data.device_name}</>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{format(new Date(activity.timestamp), 'MMM dd, HH:mm:ss')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{recentActivity.length === 0 && (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
No recent activity
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-time Detections */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200 flex items-center justify-between">
|
||||
<h3 className="text-lg font-medium text-gray-900">Live Detections</h3>
|
||||
<div className={`flex items-center space-x-2 px-2 py-1 rounded-full text-xs ${
|
||||
connected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
<div className={`w-2 h-2 rounded-full ${
|
||||
connected ? 'bg-green-400' : 'bg-red-400'
|
||||
}`} />
|
||||
<span>{connected ? 'Live' : 'Disconnected'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200 max-h-96 overflow-y-auto">
|
||||
{recentDetections.map((detection, index) => (
|
||||
<div key={index} className="px-6 py-4 animate-fade-in">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex-shrink-0 w-2 h-2 rounded-full bg-red-400 animate-pulse" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-gray-900">
|
||||
Drone {detection.drone_id} detected
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{detection.device.name || `Device ${detection.device_id}`} •
|
||||
RSSI: {detection.rssi}dBm •
|
||||
Freq: {detection.freq}MHz
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{format(new Date(detection.server_timestamp), 'HH:mm:ss')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{recentDetections.length === 0 && (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
No recent detections
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Dashboard;
|
||||
323
client/src/pages/Detections.jsx
Normal file
323
client/src/pages/Detections.jsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
MagnifyingGlassIcon,
|
||||
FunnelIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const Detections = () => {
|
||||
const [detections, setDetections] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [pagination, setPagination] = useState({});
|
||||
const [filters, setFilters] = useState({
|
||||
device_id: '',
|
||||
drone_id: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
limit: 50,
|
||||
offset: 0
|
||||
});
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDetections();
|
||||
}, [filters]);
|
||||
|
||||
const fetchDetections = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) params.append(key, value);
|
||||
});
|
||||
|
||||
const response = await api.get(`/detections?${params}`);
|
||||
setDetections(response.data.data);
|
||||
setPagination(response.data.pagination);
|
||||
} catch (error) {
|
||||
console.error('Error fetching detections:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFilterChange = (key, value) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
[key]: value,
|
||||
offset: 0 // Reset to first page when filtering
|
||||
}));
|
||||
};
|
||||
|
||||
const handlePageChange = (newOffset) => {
|
||||
setFilters(prev => ({
|
||||
...prev,
|
||||
offset: newOffset
|
||||
}));
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setFilters({
|
||||
device_id: '',
|
||||
drone_id: '',
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
limit: 50,
|
||||
offset: 0
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Drone Detections
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
History of all drone detections from your devices
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="btn btn-secondary flex items-center space-x-2"
|
||||
>
|
||||
<FunnelIcon className="h-4 w-4" />
|
||||
<span>Filters</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
{showFilters && (
|
||||
<div className="bg-white p-6 rounded-lg shadow border">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device ID
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Device ID"
|
||||
value={filters.device_id}
|
||||
onChange={(e) => handleFilterChange('device_id', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Drone ID
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Drone ID"
|
||||
value={filters.drone_id}
|
||||
onChange={(e) => handleFilterChange('drone_id', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Start Date
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={filters.start_date}
|
||||
onChange={(e) => handleFilterChange('start_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
End Date
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={filters.end_date}
|
||||
onChange={(e) => handleFilterChange('end_date', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex space-x-2">
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="btn btn-secondary"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detection List */}
|
||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="table-responsive">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Drone ID</th>
|
||||
<th>Type</th>
|
||||
<th>Frequency</th>
|
||||
<th>RSSI</th>
|
||||
<th>Location</th>
|
||||
<th>Detected At</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{detections.map((detection) => (
|
||||
<tr key={detection.id} className="hover:bg-gray-50">
|
||||
<td>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{detection.device?.name || `Device ${detection.device_id}`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
ID: {detection.device_id}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="px-2 py-1 bg-red-100 text-red-800 rounded-full text-sm font-medium">
|
||||
{detection.drone_id}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm text-gray-900">
|
||||
{detection.drone_type === 0 ? 'Unknown' : `Type ${detection.drone_type}`}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className="text-sm text-gray-900">
|
||||
{detection.freq} MHz
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span className={`text-sm font-medium ${
|
||||
detection.rssi > -60 ? 'text-red-600' :
|
||||
detection.rssi > -80 ? 'text-yellow-600' : 'text-green-600'
|
||||
}`}>
|
||||
{detection.rssi} dBm
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900">
|
||||
{detection.device?.location_description ||
|
||||
(detection.geo_lat && detection.geo_lon ?
|
||||
`${detection.geo_lat}, ${detection.geo_lon}` :
|
||||
'Unknown')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div className="text-sm text-gray-900">
|
||||
{format(new Date(detection.server_timestamp), 'MMM dd, yyyy')}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{format(new Date(detection.server_timestamp), 'HH:mm:ss')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
className="text-primary-600 hover:text-primary-900 text-sm"
|
||||
onClick={() => {
|
||||
// TODO: Open detection details modal
|
||||
console.log('View detection details:', detection);
|
||||
}}
|
||||
>
|
||||
<EyeIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{detections.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<MagnifyingGlassIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No detections found</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Try adjusting your search filters.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.total > 0 && (
|
||||
<div className="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||
<div className="flex-1 flex justify-between sm:hidden">
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.max(0, filters.offset - filters.limit))}
|
||||
disabled={filters.offset === 0}
|
||||
className="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(filters.offset + filters.limit)}
|
||||
disabled={filters.offset + filters.limit >= pagination.total}
|
||||
className="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-700">
|
||||
Showing{' '}
|
||||
<span className="font-medium">{filters.offset + 1}</span>
|
||||
{' '}to{' '}
|
||||
<span className="font-medium">
|
||||
{Math.min(filters.offset + filters.limit, pagination.total)}
|
||||
</span>
|
||||
{' '}of{' '}
|
||||
<span className="font-medium">{pagination.total}</span>
|
||||
{' '}results
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
|
||||
<button
|
||||
onClick={() => handlePageChange(Math.max(0, filters.offset - filters.limit))}
|
||||
disabled={filters.offset === 0}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePageChange(filters.offset + filters.limit)}
|
||||
disabled={filters.offset + filters.limit >= pagination.total}
|
||||
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Detections;
|
||||
437
client/src/pages/Devices.jsx
Normal file
437
client/src/pages/Devices.jsx
Normal file
@@ -0,0 +1,437 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
ServerIcon,
|
||||
MapPinIcon,
|
||||
SignalIcon,
|
||||
BoltIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
const Devices = () => {
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingDevice, setEditingDevice] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
}, []);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const response = await api.get('/devices?include_stats=true');
|
||||
setDevices(response.data.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching devices:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDevice = () => {
|
||||
setEditingDevice(null);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleEditDevice = (device) => {
|
||||
setEditingDevice(device);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteDevice = async (deviceId) => {
|
||||
if (window.confirm('Are you sure you want to deactivate this device?')) {
|
||||
try {
|
||||
await api.delete(`/devices/${deviceId}`);
|
||||
fetchDevices();
|
||||
} catch (error) {
|
||||
console.error('Error deleting device:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-green-100 text-green-800';
|
||||
case 'offline':
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getSignalStrength = (lastHeartbeat) => {
|
||||
if (!lastHeartbeat) return 'Unknown';
|
||||
|
||||
const timeSince = (new Date() - new Date(lastHeartbeat)) / 1000 / 60; // minutes
|
||||
if (timeSince < 5) return 'Strong';
|
||||
if (timeSince < 15) return 'Good';
|
||||
if (timeSince < 60) return 'Weak';
|
||||
return 'Lost';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Devices
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Manage your drone detection devices
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddDevice}
|
||||
className="btn btn-primary flex items-center space-x-2"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
<span>Add Device</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Device Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{devices.map((device) => (
|
||||
<div key={device.id} className="bg-white rounded-lg shadow border border-gray-200 hover:shadow-md transition-shadow">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
device.stats?.status === 'online' ? 'bg-green-400' : 'bg-red-400'
|
||||
}`} />
|
||||
<h4 className="text-lg font-medium text-gray-900">
|
||||
{device.name || `Device ${device.id}`}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
<button
|
||||
onClick={() => handleEditDevice(device)}
|
||||
className="p-1 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteDevice(device.id)}
|
||||
className="p-1 text-gray-400 hover:text-red-600"
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Status</span>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
getStatusColor(device.stats?.status)
|
||||
}`}>
|
||||
{device.stats?.status || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Device ID</span>
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{device.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{device.location_description && (
|
||||
<div className="flex items-start justify-between">
|
||||
<span className="text-sm text-gray-500">Location</span>
|
||||
<span className="text-sm text-gray-900 text-right">
|
||||
{device.location_description}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(device.geo_lat && device.geo_lon) && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Coordinates</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{device.geo_lat}, {device.geo_lon}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Signal</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{getSignalStrength(device.last_heartbeat)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{device.stats && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Detections (24h)</span>
|
||||
<span className={`text-sm font-medium ${
|
||||
device.stats.detections_24h > 0 ? 'text-red-600' : 'text-green-600'
|
||||
}`}>
|
||||
{device.stats.detections_24h}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.last_heartbeat && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Last Seen</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{format(new Date(device.last_heartbeat), 'MMM dd, HH:mm')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{device.firmware_version && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Firmware</span>
|
||||
<span className="text-sm text-gray-900">
|
||||
{device.firmware_version}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Device Actions */}
|
||||
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||
<div className="flex space-x-2">
|
||||
<button className="flex-1 text-xs bg-gray-100 text-gray-700 py-2 px-3 rounded hover:bg-gray-200 transition-colors">
|
||||
View Details
|
||||
</button>
|
||||
<button className="flex-1 text-xs bg-primary-100 text-primary-700 py-2 px-3 rounded hover:bg-primary-200 transition-colors">
|
||||
View on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{devices.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<ServerIcon className="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No devices</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Get started by adding your first drone detection device.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={handleAddDevice}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 mr-2" />
|
||||
Add Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Device Modal */}
|
||||
{showAddModal && (
|
||||
<DeviceModal
|
||||
device={editingDevice}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={() => {
|
||||
setShowAddModal(false);
|
||||
fetchDevices();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DeviceModal = ({ device, onClose, onSave }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
id: device?.id || '',
|
||||
name: device?.name || '',
|
||||
geo_lat: device?.geo_lat || '',
|
||||
geo_lon: device?.geo_lon || '',
|
||||
location_description: device?.location_description || '',
|
||||
heartbeat_interval: device?.heartbeat_interval || 300,
|
||||
firmware_version: device?.firmware_version || '',
|
||||
notes: device?.notes || ''
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
if (device) {
|
||||
// Update existing device
|
||||
await api.put(`/devices/${device.id}`, formData);
|
||||
} else {
|
||||
// Create new device
|
||||
await api.post('/devices', formData);
|
||||
}
|
||||
onSave();
|
||||
} catch (error) {
|
||||
console.error('Error saving device:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||
<div className="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity" onClick={onClose} />
|
||||
|
||||
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
{device ? 'Edit Device' : 'Add New Device'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!device && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device ID *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="id"
|
||||
required
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.id}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Device Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Latitude
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
name="geo_lat"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.geo_lat}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Longitude
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
name="geo_lon"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.geo_lon}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Location Description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="location_description"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.location_description}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Heartbeat Interval (seconds)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="heartbeat_interval"
|
||||
min="60"
|
||||
max="3600"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.heartbeat_interval}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Notes
|
||||
</label>
|
||||
<textarea
|
||||
name="notes"
|
||||
rows="3"
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
value={formData.notes}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-primary-600 text-base font-medium text-white hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:ml-3 sm:w-auto sm:text-sm disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : (device ? 'Update' : 'Create')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Devices;
|
||||
134
client/src/pages/Login.jsx
Normal file
134
client/src/pages/Login.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { EyeIcon, EyeSlashIcon } from '@heroicons/react/24/outline';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
const Login = () => {
|
||||
const [credentials, setCredentials] = useState({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const { login, loading, isAuthenticated } = useAuth();
|
||||
|
||||
if (isAuthenticated) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!credentials.username || !credentials.password) {
|
||||
toast.error('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await login(credentials);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Login successful!');
|
||||
} else {
|
||||
toast.error(result.error || 'Login failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (e) => {
|
||||
setCredentials({
|
||||
...credentials,
|
||||
[e.target.name]: e.target.value
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-md w-full space-y-8">
|
||||
<div>
|
||||
<div className="mx-auto h-12 w-12 bg-primary-600 rounded-lg flex items-center justify-center">
|
||||
<svg className="h-8 w-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||
Drone Detection System
|
||||
</h2>
|
||||
<p className="mt-2 text-center text-sm text-gray-600">
|
||||
Sign in to your account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
|
||||
<div className="rounded-md shadow-sm -space-y-px">
|
||||
<div>
|
||||
<label htmlFor="username" className="sr-only">
|
||||
Username or Email
|
||||
</label>
|
||||
<input
|
||||
id="username"
|
||||
name="username"
|
||||
type="text"
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Username or Email"
|
||||
value={credentials.username}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<label htmlFor="password" className="sr-only">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
name="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
className="appearance-none rounded-none relative block w-full px-3 py-2 pr-10 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-primary-500 focus:border-primary-500 focus:z-10 sm:text-sm"
|
||||
placeholder="Password"
|
||||
value={credentials.password}
|
||||
onChange={handleChange}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
) : (
|
||||
'Sign in'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-600">
|
||||
Demo credentials: <br />
|
||||
Username: <code className="bg-gray-100 px-1 rounded">admin</code> <br />
|
||||
Password: <code className="bg-gray-100 px-1 rounded">password</code>
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
283
client/src/pages/MapView.jsx
Normal file
283
client/src/pages/MapView.jsx
Normal file
@@ -0,0 +1,283 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||
import { Icon } from 'leaflet';
|
||||
import { useSocket } from '../contexts/SocketContext';
|
||||
import api from '../services/api';
|
||||
import { format } from 'date-fns';
|
||||
import {
|
||||
ServerIcon,
|
||||
ExclamationTriangleIcon,
|
||||
SignalIcon,
|
||||
EyeIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
// Fix for default markers in React Leaflet
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import iconRetinaUrl from 'leaflet/dist/images/marker-icon-2x.png';
|
||||
import iconUrl from 'leaflet/dist/images/marker-icon.png';
|
||||
import shadowUrl from 'leaflet/dist/images/marker-shadow.png';
|
||||
|
||||
delete Icon.Default.prototype._getIconUrl;
|
||||
Icon.Default.mergeOptions({
|
||||
iconRetinaUrl,
|
||||
iconUrl,
|
||||
shadowUrl,
|
||||
});
|
||||
|
||||
// Custom icons
|
||||
const createDeviceIcon = (status, hasDetections) => {
|
||||
let color = '#6b7280'; // gray for offline/inactive
|
||||
|
||||
if (status === 'online') {
|
||||
color = hasDetections ? '#ef4444' : '#22c55e'; // red if detecting, green if online
|
||||
}
|
||||
|
||||
return new Icon({
|
||||
iconUrl: `data:image/svg+xml;base64,${btoa(`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="32" height="32">
|
||||
<circle cx="12" cy="12" r="10" fill="${color}" stroke="#fff" stroke-width="2"/>
|
||||
<path d="M12 8v4l3 3" stroke="#fff" stroke-width="2" fill="none"/>
|
||||
</svg>
|
||||
`)}`,
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16],
|
||||
popupAnchor: [0, -16],
|
||||
});
|
||||
};
|
||||
|
||||
const MapView = () => {
|
||||
const [devices, setDevices] = useState([]);
|
||||
const [selectedDevice, setSelectedDevice] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mapCenter, setMapCenter] = useState([59.3293, 18.0686]); // Stockholm default
|
||||
const [mapZoom, setMapZoom] = useState(10);
|
||||
const { recentDetections, deviceStatus } = useSocket();
|
||||
|
||||
useEffect(() => {
|
||||
fetchDevices();
|
||||
const interval = setInterval(fetchDevices, 30000); // Refresh every 30 seconds
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const fetchDevices = async () => {
|
||||
try {
|
||||
const response = await api.get('/devices/map');
|
||||
const deviceData = response.data.data;
|
||||
|
||||
setDevices(deviceData);
|
||||
|
||||
// Set map center to first device with valid coordinates
|
||||
const deviceWithCoords = deviceData.find(d => d.geo_lat && d.geo_lon);
|
||||
if (deviceWithCoords && devices.length === 0) {
|
||||
setMapCenter([deviceWithCoords.geo_lat, deviceWithCoords.geo_lon]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching devices:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getDeviceStatus = (device) => {
|
||||
const realtimeStatus = deviceStatus[device.id];
|
||||
if (realtimeStatus) {
|
||||
return realtimeStatus.status;
|
||||
}
|
||||
return device.status || 'offline';
|
||||
};
|
||||
|
||||
const getDeviceDetections = (deviceId) => {
|
||||
return recentDetections.filter(d => d.device_id === deviceId);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-96">
|
||||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-primary-600"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
||||
Device Map
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
Real-time view of all devices and their detection status
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Map */}
|
||||
<div className="bg-white rounded-lg shadow-lg overflow-hidden">
|
||||
<div className="h-96 lg:h-[600px]">
|
||||
<MapContainer
|
||||
center={mapCenter}
|
||||
zoom={mapZoom}
|
||||
className="h-full w-full"
|
||||
whenCreated={(map) => {
|
||||
// Auto-fit to device locations if available
|
||||
const validDevices = devices.filter(d => d.geo_lat && d.geo_lon);
|
||||
if (validDevices.length > 1) {
|
||||
const bounds = validDevices.map(d => [d.geo_lat, d.geo_lon]);
|
||||
map.fitBounds(bounds, { padding: [20, 20] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
|
||||
{devices
|
||||
.filter(device => device.geo_lat && device.geo_lon)
|
||||
.map(device => {
|
||||
const status = getDeviceStatus(device);
|
||||
const detections = getDeviceDetections(device.id);
|
||||
const hasRecentDetections = detections.length > 0;
|
||||
|
||||
return (
|
||||
<Marker
|
||||
key={device.id}
|
||||
position={[device.geo_lat, device.geo_lon]}
|
||||
icon={createDeviceIcon(status, hasRecentDetections)}
|
||||
eventHandlers={{
|
||||
click: () => setSelectedDevice(device),
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<DevicePopup
|
||||
device={device}
|
||||
status={status}
|
||||
detections={detections}
|
||||
/>
|
||||
</Popup>
|
||||
</Marker>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Device List */}
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-medium text-gray-900">Device Status</h3>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-200">
|
||||
{devices.map(device => {
|
||||
const status = getDeviceStatus(device);
|
||||
const detections = getDeviceDetections(device.id);
|
||||
|
||||
return (
|
||||
<DeviceListItem
|
||||
key={device.id}
|
||||
device={device}
|
||||
status={status}
|
||||
detections={detections}
|
||||
onClick={() => setSelectedDevice(device)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{devices.length === 0 && (
|
||||
<div className="px-6 py-8 text-center text-gray-500">
|
||||
No devices found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DevicePopup = ({ device, status, detections }) => (
|
||||
<div className="p-2 min-w-[200px]">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-semibold text-gray-900">
|
||||
{device.name || `Device ${device.id}`}
|
||||
</h4>
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
status === 'online'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{device.location_description && (
|
||||
<p className="text-sm text-gray-600 mb-2">
|
||||
{device.location_description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div>ID: {device.id}</div>
|
||||
<div>Coordinates: {device.geo_lat}, {device.geo_lon}</div>
|
||||
{device.last_heartbeat && (
|
||||
<div>
|
||||
Last seen: {format(new Date(device.last_heartbeat), 'MMM dd, HH:mm')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detections.length > 0 && (
|
||||
<div className="mt-3 pt-2 border-t border-gray-200">
|
||||
<div className="flex items-center space-x-1 text-red-600 text-sm font-medium mb-1">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
<span>{detections.length} recent detection{detections.length > 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{detections.slice(0, 3).map((detection, index) => (
|
||||
<div key={index} className="text-xs text-gray-600">
|
||||
Drone {detection.drone_id} • {detection.freq}MHz • {detection.rssi}dBm
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const DeviceListItem = ({ device, status, detections, onClick }) => (
|
||||
<div
|
||||
className="px-6 py-4 hover:bg-gray-50 cursor-pointer transition-colors"
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`w-3 h-3 rounded-full ${
|
||||
status === 'online'
|
||||
? detections.length > 0 ? 'bg-red-400 animate-pulse' : 'bg-green-400'
|
||||
: 'bg-gray-400'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{device.name || `Device ${device.id}`}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{device.location_description || `${device.geo_lat}, ${device.geo_lon}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{detections.length > 0 && (
|
||||
<div className="flex items-center space-x-1 text-red-600">
|
||||
<ExclamationTriangleIcon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{detections.length}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
status === 'online'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
{status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MapView;
|
||||
41
client/src/services/api.js
Normal file
41
client/src/services/api.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const API_BASE_URL = process.env.NODE_ENV === 'production'
|
||||
? '/api'
|
||||
: 'http://localhost:3001/api';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor to add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor for error handling
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response?.status === 401) {
|
||||
// Token expired or invalid
|
||||
localStorage.removeItem('token');
|
||||
window.location.href = '/login';
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
77
client/tailwind.config.js
Normal file
77
client/tailwind.config.js
Normal file
@@ -0,0 +1,77 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
danger: {
|
||||
50: '#fef2f2',
|
||||
100: '#fee2e2',
|
||||
200: '#fecaca',
|
||||
300: '#fca5a5',
|
||||
400: '#f87171',
|
||||
500: '#ef4444',
|
||||
600: '#dc2626',
|
||||
700: '#b91c1c',
|
||||
800: '#991b1b',
|
||||
900: '#7f1d1d',
|
||||
},
|
||||
success: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
warning: {
|
||||
50: '#fffbeb',
|
||||
100: '#fef3c7',
|
||||
200: '#fde68a',
|
||||
300: '#fcd34d',
|
||||
400: '#fbbf24',
|
||||
500: '#f59e0b',
|
||||
600: '#d97706',
|
||||
700: '#b45309',
|
||||
800: '#92400e',
|
||||
900: '#78350f',
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
19
client/vite.config.js
Normal file
19
client/vite.config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: true
|
||||
}
|
||||
})
|
||||
152
docker-compose.yml
Normal file
152
docker-compose.yml
Normal file
@@ -0,0 +1,152 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# PostgreSQL Database
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
container_name: drone-detection-db
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: drone_detection
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres123
|
||||
PGDATA: /var/lib/postgresql/data/pgdata
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
- ./server/scripts/init-db.sql:/docker-entrypoint-initdb.d/init-db.sql
|
||||
ports:
|
||||
- "5432:5432"
|
||||
networks:
|
||||
- drone-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres -d drone_detection"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Redis for session management and caching
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: drone-detection-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
networks:
|
||||
- drone-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Backend API Server
|
||||
backend:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
container_name: drone-detection-backend
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
PORT: 3001
|
||||
DB_HOST: postgres
|
||||
DB_PORT: 5432
|
||||
DB_NAME: drone_detection
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres123
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
|
||||
TWILIO_ACCOUNT_SID: ${TWILIO_ACCOUNT_SID}
|
||||
TWILIO_AUTH_TOKEN: ${TWILIO_AUTH_TOKEN}
|
||||
TWILIO_PHONE_NUMBER: ${TWILIO_PHONE_NUMBER}
|
||||
CORS_ORIGIN: http://localhost:3000
|
||||
ports:
|
||||
- "3001:3001"
|
||||
volumes:
|
||||
- ./server/logs:/app/logs
|
||||
networks:
|
||||
- drone-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:3001/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Frontend React Application
|
||||
frontend:
|
||||
build:
|
||||
context: ./client
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
VITE_API_URL: http://localhost:3001/api
|
||||
VITE_WS_URL: ws://localhost:3001
|
||||
container_name: drone-detection-frontend
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:80"
|
||||
networks:
|
||||
- drone-network
|
||||
depends_on:
|
||||
- backend
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:80"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# Nginx Reverse Proxy (Production)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: drone-detection-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./docker/nginx/nginx.conf:/etc/nginx/nginx.conf
|
||||
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
|
||||
- ./docker/ssl:/etc/nginx/ssl
|
||||
- ./client/dist:/usr/share/nginx/html
|
||||
networks:
|
||||
- drone-network
|
||||
depends_on:
|
||||
- frontend
|
||||
- backend
|
||||
profiles:
|
||||
- production
|
||||
|
||||
# Python Drone Simulator (Optional)
|
||||
simulator:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/simulator/Dockerfile
|
||||
container_name: drone-detection-simulator
|
||||
restart: "no"
|
||||
environment:
|
||||
API_URL: http://backend:3001/api
|
||||
networks:
|
||||
- drone-network
|
||||
depends_on:
|
||||
- backend
|
||||
profiles:
|
||||
- simulation
|
||||
command: python drone_simulator.py --devices 5 --duration 3600
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
driver: local
|
||||
redis_data:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
drone-network:
|
||||
driver: bridge
|
||||
154
docker-start.bat
Normal file
154
docker-start.bat
Normal file
@@ -0,0 +1,154 @@
|
||||
@echo off
|
||||
setlocal enabledelayedexpansion
|
||||
|
||||
REM Drone Detection System - Docker Quick Start Script (Windows)
|
||||
REM This script sets up and starts the complete system using Docker
|
||||
|
||||
echo 🐳 Drone Detection System - Docker Setup
|
||||
echo ========================================
|
||||
|
||||
REM Check if Docker is installed
|
||||
docker --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Docker is not installed. Please install Docker Desktop first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
REM Check if Docker Compose is installed
|
||||
docker-compose --version >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo [ERROR] Docker Compose is not installed. Please install Docker Compose first.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [INFO] Docker and Docker Compose are available
|
||||
|
||||
REM Create .env file if it doesn't exist
|
||||
if not exist .env (
|
||||
echo [INFO] Creating .env file from template...
|
||||
copy .env.docker .env >nul
|
||||
echo [WARNING] Please edit .env file with your Twilio credentials before continuing
|
||||
echo Required variables:
|
||||
echo - TWILIO_ACCOUNT_SID
|
||||
echo - TWILIO_AUTH_TOKEN
|
||||
echo - TWILIO_PHONE_NUMBER
|
||||
echo.
|
||||
pause
|
||||
)
|
||||
|
||||
REM Parse command line arguments
|
||||
set PROFILE=default
|
||||
set DETACH=-d
|
||||
set BUILD=
|
||||
|
||||
:parse_args
|
||||
if "%~1"=="" goto start_services
|
||||
if "%~1"=="--production" set PROFILE=production
|
||||
if "%~1"=="-p" set PROFILE=production
|
||||
if "%~1"=="--simulation" set PROFILE=simulation
|
||||
if "%~1"=="-s" set PROFILE=simulation
|
||||
if "%~1"=="--foreground" set DETACH=
|
||||
if "%~1"=="-f" set DETACH=
|
||||
if "%~1"=="--build" set BUILD=--build
|
||||
if "%~1"=="-b" set BUILD=--build
|
||||
if "%~1"=="--help" goto show_help
|
||||
if "%~1"=="-h" goto show_help
|
||||
shift
|
||||
goto parse_args
|
||||
|
||||
:show_help
|
||||
echo Usage: %~nx0 [OPTIONS]
|
||||
echo.
|
||||
echo Options:
|
||||
echo -p, --production Run with production profile (includes Nginx)
|
||||
echo -s, --simulation Run with simulation profile (includes drone simulator)
|
||||
echo -f, --foreground Run in foreground (don't detach)
|
||||
echo -b, --build Force rebuild of containers
|
||||
echo -h, --help Show this help message
|
||||
echo.
|
||||
echo Examples:
|
||||
echo %~nx0 # Start basic system
|
||||
echo %~nx0 -p # Start with production setup
|
||||
echo %~nx0 -s -f # Start with simulation in foreground
|
||||
echo %~nx0 -b # Rebuild and start
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:start_services
|
||||
REM Stop any existing containers
|
||||
echo [INFO] Stopping any existing containers...
|
||||
docker-compose down 2>nul
|
||||
|
||||
REM Build containers if requested
|
||||
if not "%BUILD%"=="" (
|
||||
echo [INFO] Building Docker containers...
|
||||
docker-compose build
|
||||
)
|
||||
|
||||
REM Start the appropriate profile
|
||||
if "%PROFILE%"=="production" (
|
||||
echo [INFO] Starting production environment...
|
||||
docker-compose --profile production up %DETACH% %BUILD%
|
||||
) else if "%PROFILE%"=="simulation" (
|
||||
echo [INFO] Starting with simulation environment...
|
||||
docker-compose --profile simulation up %DETACH% %BUILD%
|
||||
) else (
|
||||
echo [INFO] Starting development environment...
|
||||
docker-compose up %DETACH% %BUILD%
|
||||
)
|
||||
|
||||
REM Wait a moment for services to start
|
||||
if not "%DETACH%"=="" (
|
||||
echo [INFO] Waiting for services to start...
|
||||
timeout /t 10 /nobreak >nul
|
||||
|
||||
echo [INFO] Checking service health...
|
||||
|
||||
REM Check backend
|
||||
curl -sf http://localhost:3001/api/health >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo [SUCCESS] Backend is healthy
|
||||
) else (
|
||||
echo [WARNING] Backend health check failed
|
||||
)
|
||||
|
||||
REM Check frontend
|
||||
curl -sf http://localhost:3000 >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
echo [SUCCESS] Frontend is healthy
|
||||
) else (
|
||||
echo [WARNING] Frontend health check failed
|
||||
)
|
||||
|
||||
echo.
|
||||
echo [SUCCESS] 🎉 Drone Detection System is running!
|
||||
echo.
|
||||
echo Access URLs:
|
||||
echo Frontend: http://localhost:3000
|
||||
echo Backend: http://localhost:3001/api
|
||||
echo Health: http://localhost:3001/api/health
|
||||
echo.
|
||||
echo Default login credentials:
|
||||
echo Admin: admin / admin123
|
||||
echo Operator: operator / operator123
|
||||
echo.
|
||||
echo Useful commands:
|
||||
echo docker-compose logs -f # View logs
|
||||
echo docker-compose ps # Check status
|
||||
echo docker-compose down # Stop services
|
||||
echo docker-compose restart backend # Restart a service
|
||||
echo.
|
||||
|
||||
if "%PROFILE%"=="simulation" (
|
||||
echo 🐍 Simulation is running!
|
||||
echo Monitor with: docker-compose logs -f simulator
|
||||
echo.
|
||||
)
|
||||
) else (
|
||||
echo.
|
||||
echo [INFO] Services are running in foreground. Press Ctrl+C to stop.
|
||||
)
|
||||
|
||||
pause
|
||||
191
docker-start.sh
Normal file
191
docker-start.sh
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Drone Detection System - Docker Quick Start Script
|
||||
# This script sets up and starts the complete system using Docker
|
||||
|
||||
set -e
|
||||
|
||||
echo "🐳 Drone Detection System - Docker Setup"
|
||||
echo "========================================"
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# Function to print colored output
|
||||
print_status() {
|
||||
echo -e "${BLUE}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_success() {
|
||||
echo -e "${GREEN}[SUCCESS]${NC} $1"
|
||||
}
|
||||
|
||||
print_warning() {
|
||||
echo -e "${YELLOW}[WARNING]${NC} $1"
|
||||
}
|
||||
|
||||
print_error() {
|
||||
echo -e "${RED}[ERROR]${NC} $1"
|
||||
}
|
||||
|
||||
# Check if Docker is installed
|
||||
if ! command -v docker &> /dev/null; then
|
||||
print_error "Docker is not installed. Please install Docker first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if Docker Compose is installed
|
||||
if ! command -v docker-compose &> /dev/null; then
|
||||
print_error "Docker Compose is not installed. Please install Docker Compose first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
print_status "Docker and Docker Compose are available"
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f .env ]; then
|
||||
print_status "Creating .env file from template..."
|
||||
cp .env.docker .env
|
||||
print_warning "Please edit .env file with your Twilio credentials before continuing"
|
||||
echo "Required variables:"
|
||||
echo " - TWILIO_ACCOUNT_SID"
|
||||
echo " - TWILIO_AUTH_TOKEN"
|
||||
echo " - TWILIO_PHONE_NUMBER"
|
||||
echo ""
|
||||
read -p "Press Enter to continue when .env is configured..."
|
||||
fi
|
||||
|
||||
# Parse command line arguments
|
||||
PROFILE="default"
|
||||
DETACH="-d"
|
||||
BUILD=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--production|-p)
|
||||
PROFILE="production"
|
||||
shift
|
||||
;;
|
||||
--simulation|-s)
|
||||
PROFILE="simulation"
|
||||
shift
|
||||
;;
|
||||
--foreground|-f)
|
||||
DETACH=""
|
||||
shift
|
||||
;;
|
||||
--build|-b)
|
||||
BUILD="--build"
|
||||
shift
|
||||
;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
echo ""
|
||||
echo "Options:"
|
||||
echo " -p, --production Run with production profile (includes Nginx)"
|
||||
echo " -s, --simulation Run with simulation profile (includes drone simulator)"
|
||||
echo " -f, --foreground Run in foreground (don't detach)"
|
||||
echo " -b, --build Force rebuild of containers"
|
||||
echo " -h, --help Show this help message"
|
||||
echo ""
|
||||
echo "Examples:"
|
||||
echo " $0 # Start basic system"
|
||||
echo " $0 -p # Start with production setup"
|
||||
echo " $0 -s -f # Start with simulation in foreground"
|
||||
echo " $0 -b # Rebuild and start"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
print_error "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Stop any existing containers
|
||||
print_status "Stopping any existing containers..."
|
||||
docker-compose down 2>/dev/null || true
|
||||
|
||||
# Build containers if requested
|
||||
if [ -n "$BUILD" ]; then
|
||||
print_status "Building Docker containers..."
|
||||
docker-compose build
|
||||
fi
|
||||
|
||||
# Start the appropriate profile
|
||||
case $PROFILE in
|
||||
"production")
|
||||
print_status "Starting production environment..."
|
||||
docker-compose --profile production up $DETACH $BUILD
|
||||
;;
|
||||
"simulation")
|
||||
print_status "Starting with simulation environment..."
|
||||
docker-compose --profile simulation up $DETACH $BUILD
|
||||
;;
|
||||
*)
|
||||
print_status "Starting development environment..."
|
||||
docker-compose up $DETACH $BUILD
|
||||
;;
|
||||
esac
|
||||
|
||||
# Wait a moment for services to start
|
||||
if [ -n "$DETACH" ]; then
|
||||
print_status "Waiting for services to start..."
|
||||
sleep 10
|
||||
|
||||
# Check service health
|
||||
print_status "Checking service health..."
|
||||
|
||||
# Check backend
|
||||
if curl -sf http://localhost:3001/api/health > /dev/null 2>&1; then
|
||||
print_success "Backend is healthy"
|
||||
else
|
||||
print_warning "Backend health check failed"
|
||||
fi
|
||||
|
||||
# Check frontend
|
||||
if curl -sf http://localhost:3000 > /dev/null 2>&1; then
|
||||
print_success "Frontend is healthy"
|
||||
else
|
||||
print_warning "Frontend health check failed"
|
||||
fi
|
||||
|
||||
# Check database
|
||||
if docker-compose exec -T postgres pg_isready -U postgres > /dev/null 2>&1; then
|
||||
print_success "Database is healthy"
|
||||
else
|
||||
print_warning "Database health check failed"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
print_success "🎉 Drone Detection System is running!"
|
||||
echo ""
|
||||
echo "Access URLs:"
|
||||
echo " Frontend: http://localhost:3000"
|
||||
echo " Backend: http://localhost:3001/api"
|
||||
echo " Health: http://localhost:3001/api/health"
|
||||
echo ""
|
||||
echo "Default login credentials:"
|
||||
echo " Admin: admin / admin123"
|
||||
echo " Operator: operator / operator123"
|
||||
echo ""
|
||||
echo "Useful commands:"
|
||||
echo " docker-compose logs -f # View logs"
|
||||
echo " docker-compose ps # Check status"
|
||||
echo " docker-compose down # Stop services"
|
||||
echo " docker-compose restart backend # Restart a service"
|
||||
echo ""
|
||||
|
||||
if [ "$PROFILE" = "simulation" ]; then
|
||||
echo "🐍 Simulation is running!"
|
||||
echo " Monitor with: docker-compose logs -f simulator"
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
print_status "Services are running in foreground. Press Ctrl+C to stop."
|
||||
fi
|
||||
85
docker/nginx/default.conf
Normal file
85
docker/nginx/default.conf
Normal file
@@ -0,0 +1,85 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Content-Security-Policy "default-src 'self' http: https: ws: wss: data: blob: 'unsafe-inline'; frame-ancestors 'self';" always;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private must-revalidate auth;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/xml+rss
|
||||
application/json;
|
||||
|
||||
# Handle React Router (SPA)
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
# Proxy API requests to backend
|
||||
location /api {
|
||||
proxy_pass http://backend:3001;
|
||||
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;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
# WebSocket proxy for Socket.IO
|
||||
location /socket.io/ {
|
||||
proxy_pass http://backend:3001;
|
||||
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_read_timeout 86400;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Security.txt
|
||||
location /.well-known/security.txt {
|
||||
return 200 "Contact: security@example.com\nExpires: 2025-12-31T23:59:59.000Z\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Deny access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
61
docker/nginx/nginx.conf
Normal file
61
docker/nginx/nginx.conf
Normal file
@@ -0,0 +1,61 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log notice;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# Logging format
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# Performance optimizations
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
client_max_body_size 16M;
|
||||
|
||||
# Hide nginx version
|
||||
server_tokens off;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_comp_level 6;
|
||||
gzip_proxied any;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/xml
|
||||
text/javascript
|
||||
text/x-component
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/xml
|
||||
application/json
|
||||
application/xhtml+xml
|
||||
application/rss+xml
|
||||
application/atom+xml
|
||||
image/svg+xml;
|
||||
|
||||
# Rate limiting
|
||||
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
|
||||
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
|
||||
|
||||
# Include server configurations
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
||||
32
docker/simulator/Dockerfile
Normal file
32
docker/simulator/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# Python Simulator Dockerfile for Drone Detection System
|
||||
FROM python:3.11-alpine AS base
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
curl \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy requirements file
|
||||
COPY requirements.txt .
|
||||
|
||||
# Install Python dependencies
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Copy simulator script
|
||||
COPY drone_simulator.py .
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S simulator && \
|
||||
adduser -S simulator -u 1001
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R simulator:simulator /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER simulator
|
||||
|
||||
# Default command (can be overridden)
|
||||
CMD ["python", "drone_simulator.py", "--api-url", "http://backend:3001/api", "--devices", "5", "--duration", "3600"]
|
||||
393
docs/DOCKER_DEPLOYMENT.md
Normal file
393
docs/DOCKER_DEPLOYMENT.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# Docker Deployment Guide
|
||||
|
||||
This guide covers deploying the Drone Detection System using Docker and Docker Compose.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker Engine 20.10+
|
||||
- Docker Compose 2.0+
|
||||
- At least 4GB RAM
|
||||
- 10GB available disk space
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Environment Setup
|
||||
|
||||
```bash
|
||||
# Copy environment template
|
||||
cp .env.docker .env
|
||||
|
||||
# Edit .env with your Twilio credentials
|
||||
nano .env
|
||||
```
|
||||
|
||||
### 2. Basic Deployment
|
||||
|
||||
```bash
|
||||
# Build and start all services
|
||||
docker-compose up -d
|
||||
|
||||
# View logs
|
||||
docker-compose logs -f
|
||||
|
||||
# Check service status
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
### 3. Access the Application
|
||||
|
||||
- **Frontend**: http://localhost:3000
|
||||
- **Backend API**: http://localhost:3001/api
|
||||
- **Database**: localhost:5432
|
||||
- **Redis**: localhost:6379
|
||||
|
||||
## Service Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Frontend │ │ Backend │ │ PostgreSQL │
|
||||
│ (React) │◄──►│ (Node.js) │◄──►│ Database │
|
||||
│ Port: 3000 │ │ Port: 3001 │ │ Port: 5432 │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
│ ┌─────────────────┐ │
|
||||
└──────────────►│ Redis │◄────────────┘
|
||||
│ (Caching) │
|
||||
│ Port: 6379 │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Docker Compose Profiles
|
||||
|
||||
### Development Profile (Default)
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
Includes: Frontend, Backend, Database, Redis
|
||||
|
||||
### Production Profile
|
||||
```bash
|
||||
docker-compose --profile production up -d
|
||||
```
|
||||
Includes: All services + Nginx reverse proxy
|
||||
|
||||
### Simulation Profile
|
||||
```bash
|
||||
docker-compose --profile simulation up -d
|
||||
```
|
||||
Includes: All services + Python drone simulator
|
||||
|
||||
## Service Details
|
||||
|
||||
### Frontend Container
|
||||
- **Image**: Custom Nginx + React build
|
||||
- **Port**: 3000:80
|
||||
- **Features**:
|
||||
- Gzip compression
|
||||
- SPA routing support
|
||||
- API proxying
|
||||
- Security headers
|
||||
|
||||
### Backend Container
|
||||
- **Image**: Node.js 18 Alpine
|
||||
- **Port**: 3001:3001
|
||||
- **Features**:
|
||||
- Health checks
|
||||
- Non-root user
|
||||
- Log persistence
|
||||
- Signal handling
|
||||
|
||||
### Database Container
|
||||
- **Image**: PostgreSQL 15 Alpine
|
||||
- **Port**: 5432:5432
|
||||
- **Features**:
|
||||
- Persistent storage
|
||||
- Health checks
|
||||
- Initialization scripts
|
||||
- Performance tuning
|
||||
|
||||
### Redis Container
|
||||
- **Image**: Redis 7 Alpine
|
||||
- **Port**: 6379:6379
|
||||
- **Features**:
|
||||
- Persistent storage
|
||||
- AOF logging
|
||||
- Health checks
|
||||
|
||||
### Nginx Proxy (Production)
|
||||
- **Image**: Nginx Alpine
|
||||
- **Ports**: 80:80, 443:443
|
||||
- **Features**:
|
||||
- SSL termination
|
||||
- Load balancing
|
||||
- Static file serving
|
||||
- WebSocket support
|
||||
|
||||
## Environment Variables
|
||||
|
||||
### Backend Environment
|
||||
```bash
|
||||
NODE_ENV=production
|
||||
PORT=3001
|
||||
DB_HOST=postgres
|
||||
DB_PORT=5432
|
||||
DB_NAME=drone_detection
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres123
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
JWT_SECRET=your-jwt-secret
|
||||
TWILIO_ACCOUNT_SID=your-twilio-sid
|
||||
TWILIO_AUTH_TOKEN=your-twilio-token
|
||||
TWILIO_PHONE_NUMBER=your-twilio-phone
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
```
|
||||
|
||||
### Frontend Build Arguments
|
||||
```bash
|
||||
VITE_API_URL=http://localhost:3001/api
|
||||
VITE_WS_URL=ws://localhost:3001
|
||||
```
|
||||
|
||||
## Data Persistence
|
||||
|
||||
### Volumes
|
||||
- `postgres_data`: Database files
|
||||
- `redis_data`: Redis persistence
|
||||
- `./server/logs`: Application logs
|
||||
|
||||
### Backup Strategy
|
||||
```bash
|
||||
# Database backup
|
||||
docker-compose exec postgres pg_dump -U postgres drone_detection > backup.sql
|
||||
|
||||
# Restore database
|
||||
docker-compose exec -T postgres psql -U postgres drone_detection < backup.sql
|
||||
|
||||
# Volume backup
|
||||
docker run --rm -v uamils_postgres_data:/data -v $(pwd):/backup alpine tar czf /backup/postgres_backup.tar.gz /data
|
||||
```
|
||||
|
||||
## Monitoring and Logs
|
||||
|
||||
### View Logs
|
||||
```bash
|
||||
# All services
|
||||
docker-compose logs -f
|
||||
|
||||
# Specific service
|
||||
docker-compose logs -f backend
|
||||
|
||||
# Last 100 lines
|
||||
docker-compose logs --tail=100 backend
|
||||
```
|
||||
|
||||
### Health Checks
|
||||
```bash
|
||||
# Check service health
|
||||
docker-compose ps
|
||||
|
||||
# Manual health check
|
||||
curl http://localhost:3001/api/health
|
||||
curl http://localhost:3000/health
|
||||
```
|
||||
|
||||
### Resource Monitoring
|
||||
```bash
|
||||
# Container stats
|
||||
docker stats
|
||||
|
||||
# Detailed container info
|
||||
docker-compose exec backend top
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
#### 1. Database Connection Issues
|
||||
```bash
|
||||
# Check database status
|
||||
docker-compose exec postgres pg_isready -U postgres
|
||||
|
||||
# View database logs
|
||||
docker-compose logs postgres
|
||||
|
||||
# Reset database
|
||||
docker-compose down -v
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### 2. Frontend Build Issues
|
||||
```bash
|
||||
# Rebuild frontend
|
||||
docker-compose build --no-cache frontend
|
||||
|
||||
# Check build logs
|
||||
docker-compose logs frontend
|
||||
```
|
||||
|
||||
#### 3. Backend API Issues
|
||||
```bash
|
||||
# Check backend health
|
||||
curl http://localhost:3001/api/health/detailed
|
||||
|
||||
# View backend logs
|
||||
docker-compose logs backend
|
||||
|
||||
# Restart backend
|
||||
docker-compose restart backend
|
||||
```
|
||||
|
||||
#### 4. Port Conflicts
|
||||
```bash
|
||||
# Check port usage
|
||||
netstat -tulpn | grep :3000
|
||||
netstat -tulpn | grep :3001
|
||||
|
||||
# Stop conflicting services
|
||||
docker-compose down
|
||||
```
|
||||
|
||||
### Performance Tuning
|
||||
|
||||
#### 1. Database Optimization
|
||||
```bash
|
||||
# Increase shared_buffers for PostgreSQL
|
||||
docker-compose exec postgres psql -U postgres -c "ALTER SYSTEM SET shared_buffers = '256MB';"
|
||||
docker-compose restart postgres
|
||||
```
|
||||
|
||||
#### 2. Memory Limits
|
||||
```yaml
|
||||
# Add to docker-compose.yml services
|
||||
services:
|
||||
backend:
|
||||
mem_limit: 512m
|
||||
mem_reservation: 256m
|
||||
frontend:
|
||||
mem_limit: 256m
|
||||
mem_reservation: 128m
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### 1. SSL Configuration
|
||||
```bash
|
||||
# Generate SSL certificates
|
||||
mkdir -p docker/ssl
|
||||
openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
|
||||
-keyout docker/ssl/nginx.key \
|
||||
-out docker/ssl/nginx.crt
|
||||
```
|
||||
|
||||
### 2. Environment Security
|
||||
```bash
|
||||
# Use Docker secrets for sensitive data
|
||||
echo "your-jwt-secret" | docker secret create jwt_secret -
|
||||
echo "your-twilio-token" | docker secret create twilio_token -
|
||||
```
|
||||
|
||||
### 3. Nginx Configuration
|
||||
```bash
|
||||
# Enable production profile
|
||||
docker-compose --profile production up -d
|
||||
|
||||
# Update nginx config for your domain
|
||||
# Edit docker/nginx/default.conf
|
||||
```
|
||||
|
||||
### 4. Monitoring Setup
|
||||
```bash
|
||||
# Add monitoring services
|
||||
docker-compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
|
||||
```
|
||||
|
||||
## Scaling
|
||||
|
||||
### Horizontal Scaling
|
||||
```yaml
|
||||
# Scale backend instances
|
||||
docker-compose up -d --scale backend=3
|
||||
|
||||
# Load balancer configuration required
|
||||
```
|
||||
|
||||
### Database Scaling
|
||||
```yaml
|
||||
# Add read replicas
|
||||
postgres-replica:
|
||||
image: postgres:15-alpine
|
||||
environment:
|
||||
POSTGRES_MASTER_SERVICE: postgres
|
||||
POSTGRES_REPLICA_USER: replica
|
||||
POSTGRES_REPLICA_PASSWORD: replica123
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updates
|
||||
```bash
|
||||
# Update images
|
||||
docker-compose pull
|
||||
|
||||
# Rebuild and restart
|
||||
docker-compose down
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
### Cleanup
|
||||
```bash
|
||||
# Remove unused containers
|
||||
docker system prune
|
||||
|
||||
# Remove unused volumes
|
||||
docker volume prune
|
||||
|
||||
# Clean build cache
|
||||
docker builder prune
|
||||
```
|
||||
|
||||
## Testing with Simulator
|
||||
|
||||
### Run Simulation
|
||||
```bash
|
||||
# Start simulation profile
|
||||
docker-compose --profile simulation up -d
|
||||
|
||||
# Run custom simulation
|
||||
docker-compose run --rm simulator python drone_simulator.py \
|
||||
--devices 10 \
|
||||
--duration 3600 \
|
||||
--detection-interval 30
|
||||
```
|
||||
|
||||
### Monitor Simulation
|
||||
```bash
|
||||
# View simulator logs
|
||||
docker-compose logs -f simulator
|
||||
|
||||
# Check API stats
|
||||
curl http://localhost:3001/api/dashboard/stats
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Container Security
|
||||
- Non-root users in all containers
|
||||
- Read-only root filesystems where possible
|
||||
- Limited container capabilities
|
||||
- Security scanning with `docker scan`
|
||||
|
||||
### Network Security
|
||||
- Custom bridge network isolation
|
||||
- No unnecessary port exposures
|
||||
- Internal service communication
|
||||
|
||||
### Data Security
|
||||
- Encrypted environment variables
|
||||
- SSL/TLS termination at proxy
|
||||
- Database connection encryption
|
||||
- Regular security updates
|
||||
|
||||
For additional security hardening, see [Security Best Practices](../docs/SECURITY.md).
|
||||
141
docs/SECURITY_ENHANCEMENT_SUMMARY.md
Normal file
141
docs/SECURITY_ENHANCEMENT_SUMMARY.md
Normal file
@@ -0,0 +1,141 @@
|
||||
# Enhanced Drone Detection System - Threat Assessment Summary
|
||||
|
||||
## 🚨 Security Enhancements for Government Sites
|
||||
|
||||
Your drone detection system has been significantly enhanced with intelligent threat assessment capabilities specifically designed for Swedish government sites, water facilities, nuclear plants, and other sensitive installations.
|
||||
|
||||
## 🎯 Key Security Features Added
|
||||
|
||||
### 1. **RSSI-Based Threat Classification**
|
||||
- **Critical Threats** (0-50m): Immediate security response
|
||||
- **High Threats** (50-200m): Security response recommended
|
||||
- **Medium Threats** (200m-1km): Enhanced monitoring
|
||||
- **Low Threats** (1-5km): Standard monitoring
|
||||
- **Monitoring** (5-15km): Passive surveillance
|
||||
|
||||
### 2. **Intelligent Distance Calculation**
|
||||
- Real-time distance estimation using RSSI signal strength
|
||||
- Path loss calculations adapted for outdoor security environments
|
||||
- Accurate threat zone determination for perimeter security
|
||||
|
||||
### 3. **Enhanced Alert System**
|
||||
- **Critical threats automatically trigger all alert channels**
|
||||
- Threat-specific alert messages with security descriptions
|
||||
- Immediate action notifications for high-priority threats
|
||||
- Bypasses cooldown periods for critical security situations
|
||||
|
||||
### 4. **Swedish Location Integration**
|
||||
Pre-configured monitoring for sensitive Swedish facilities:
|
||||
- Government offices and Riksdag
|
||||
- Water treatment facilities (Norsborg, Lovö, etc.)
|
||||
- Nuclear power plants (Forsmark, Ringhals, Oskarshamn)
|
||||
- Military installations (Karlsborg, Boden, etc.)
|
||||
- Major airports (Arlanda, Landvetter, etc.)
|
||||
|
||||
## 🐍 Python Simulation Script
|
||||
|
||||
### Comprehensive Testing Tool
|
||||
The `drone_simulator.py` script provides realistic testing with:
|
||||
|
||||
- **Swedish coordinates** for actual sensitive locations
|
||||
- **Threat-based scenarios** with realistic probability distributions
|
||||
- **RSSI calculations** based on actual physics formulas
|
||||
- **Continuous device monitoring** with heartbeat simulation
|
||||
- **Multiple facility types** (government, water, nuclear, military)
|
||||
|
||||
### Usage Examples
|
||||
```bash
|
||||
# Basic simulation with 5 devices
|
||||
python drone_simulator.py
|
||||
|
||||
# Extended simulation for stress testing
|
||||
python drone_simulator.py --devices 15 --duration 7200 --detection-interval 30
|
||||
|
||||
# List all available Swedish monitoring locations
|
||||
python drone_simulator.py --list-locations
|
||||
```
|
||||
|
||||
## 📊 Threat Statistics
|
||||
|
||||
The simulator generates realistic threat distributions:
|
||||
- **70%** - Low threats (5-15km range)
|
||||
- **20%** - Medium threats (200m-5km range)
|
||||
- **8%** - High threats (50-200m range)
|
||||
- **2%** - Critical threats (0-50m range)
|
||||
|
||||
## 🔧 Implementation Details
|
||||
|
||||
### Database Schema Updates
|
||||
- Added `threat_level` field to drone detections
|
||||
- Added `estimated_distance` for distance tracking
|
||||
- Added `requires_action` flag for security protocols
|
||||
|
||||
### API Enhancements
|
||||
- Real-time threat assessment processing
|
||||
- Enhanced alert message generation
|
||||
- Threat-based filtering and alerting
|
||||
|
||||
### Frontend Integration
|
||||
- Threat level indicators on maps and dashboards
|
||||
- Color-coded threat visualization
|
||||
- Enhanced alert rule configuration
|
||||
|
||||
## 📋 Recommended Configuration
|
||||
|
||||
### For Government Sites
|
||||
```javascript
|
||||
{
|
||||
"min_threat_level": "high",
|
||||
"max_distance": 200,
|
||||
"cooldown_minutes": 2,
|
||||
"channels": ["sms", "email", "webhook"]
|
||||
}
|
||||
```
|
||||
|
||||
### For Water Facilities
|
||||
```javascript
|
||||
{
|
||||
"min_threat_level": "medium",
|
||||
"max_distance": 500,
|
||||
"cooldown_minutes": 10,
|
||||
"channels": ["sms"]
|
||||
}
|
||||
```
|
||||
|
||||
### For Nuclear Facilities
|
||||
```javascript
|
||||
{
|
||||
"min_threat_level": "medium",
|
||||
"max_distance": 1000,
|
||||
"cooldown_minutes": 0,
|
||||
"channels": ["sms", "email", "webhook"],
|
||||
"force_critical_alerts": true
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 Deployment Recommendations
|
||||
|
||||
1. **Test with Simulator**: Use the Python script to generate realistic test data
|
||||
2. **Configure Threat Thresholds**: Set appropriate threat levels for each facility type
|
||||
3. **Set Up Alert Channels**: Configure SMS, email, and webhook notifications
|
||||
4. **Train Security Personnel**: Ensure staff understand threat levels and response protocols
|
||||
5. **Monitor and Adjust**: Fine-tune threat thresholds based on real-world usage
|
||||
|
||||
## 📞 Emergency Response Integration
|
||||
|
||||
The system now supports:
|
||||
- **Immediate escalation** for critical threats
|
||||
- **Security protocol activation** based on threat levels
|
||||
- **Multi-channel alerting** for redundancy
|
||||
- **Real-time threat tracking** with distance monitoring
|
||||
|
||||
## 🔒 Security Compliance
|
||||
|
||||
Features designed for:
|
||||
- **Government security standards**
|
||||
- **Critical infrastructure protection**
|
||||
- **Perimeter security monitoring**
|
||||
- **Incident response protocols**
|
||||
- **Audit and compliance logging**
|
||||
|
||||
This enhanced system provides enterprise-grade security monitoring specifically tailored for Swedish sensitive installations, with realistic testing capabilities and intelligent threat assessment.
|
||||
209
docs/THREAT_ASSESSMENT.md
Normal file
209
docs/THREAT_ASSESSMENT.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Threat Assessment and Security Features
|
||||
|
||||
## RSSI-Based Threat Classification
|
||||
|
||||
The drone detection system now includes intelligent threat assessment based on signal strength (RSSI) and drone type classification. This is specifically designed for government sites, water facilities, nuclear plants, and other sensitive Swedish installations.
|
||||
|
||||
### Threat Levels
|
||||
|
||||
The system automatically classifies detections into 5 threat levels:
|
||||
|
||||
#### 🔴 CRITICAL THREAT (RSSI ≥ -40 dBm)
|
||||
- **Distance**: 0-50 meters from device
|
||||
- **Action**: Immediate security response required
|
||||
- **Description**: Drone within security perimeter
|
||||
- **Alerts**: All available channels (SMS, email, webhook)
|
||||
|
||||
#### 🟠 HIGH THREAT (RSSI -55 to -40 dBm)
|
||||
- **Distance**: 50-200 meters from device
|
||||
- **Action**: Security response recommended
|
||||
- **Description**: Drone approaching facility
|
||||
- **Alerts**: SMS and email notifications
|
||||
|
||||
#### 🟡 MEDIUM THREAT (RSSI -70 to -55 dBm)
|
||||
- **Distance**: 200m-1km from device
|
||||
- **Action**: Enhanced monitoring
|
||||
- **Description**: Drone in facility vicinity
|
||||
- **Alerts**: SMS notifications (configurable)
|
||||
|
||||
#### 🟢 LOW THREAT (RSSI -85 to -70 dBm)
|
||||
- **Distance**: 1-5 kilometers from device
|
||||
- **Action**: Standard monitoring
|
||||
- **Description**: Drone detected at distance
|
||||
- **Alerts**: Log only (configurable)
|
||||
|
||||
#### ⚪ MONITORING (RSSI < -85 dBm)
|
||||
- **Distance**: 5-15 kilometers from device
|
||||
- **Action**: Passive monitoring
|
||||
- **Description**: Long-range detection
|
||||
- **Alerts**: Log only
|
||||
|
||||
### Drone Type Classification
|
||||
|
||||
Threat levels are adjusted based on drone type:
|
||||
|
||||
- **Type 0 (Consumer/Hobby)**: Standard threat assessment
|
||||
- **Type 1 (Professional/Military)**: Escalated threat level
|
||||
- **Type 2 (Racing/High-speed)**: Escalated if within close range
|
||||
- **Type 3 (Unknown/Custom)**: Standard threat assessment
|
||||
|
||||
### Distance Calculation
|
||||
|
||||
The system estimates drone distance using RSSI with the formula:
|
||||
```
|
||||
Distance (m) = 10^((RSSI_at_1m - RSSI) / (10 * path_loss_exponent))
|
||||
```
|
||||
|
||||
Where:
|
||||
- `RSSI_at_1m = -30 dBm` (typical RSSI at 1 meter)
|
||||
- `path_loss_exponent = 3` (outdoor environment with obstacles)
|
||||
|
||||
## Alert Rule Configuration
|
||||
|
||||
### Enhanced Alert Conditions
|
||||
|
||||
Alert rules now support advanced threat-based conditions:
|
||||
|
||||
```javascript
|
||||
{
|
||||
"conditions": {
|
||||
"min_threat_level": "high", // Minimum threat level to trigger
|
||||
"rssi_threshold": -55, // Minimum RSSI value
|
||||
"max_distance": 200, // Maximum distance in meters
|
||||
"drone_types": [0, 1, 2], // Allowed drone types
|
||||
"device_ids": [1941875381] // Specific devices to monitor
|
||||
},
|
||||
"actions": {
|
||||
"sms": true,
|
||||
"phone_number": "+46701234567",
|
||||
"email": true,
|
||||
"channels": ["sms", "email"] // Alert channels
|
||||
},
|
||||
"cooldown_minutes": 5 // Cooldown between alerts
|
||||
}
|
||||
```
|
||||
|
||||
### Security Features for Sensitive Sites
|
||||
|
||||
#### Automatic Critical Threat Handling
|
||||
- Critical threats (RSSI ≥ -40 dBm) automatically trigger all available alert channels
|
||||
- Bypasses normal cooldown periods for immediate notification
|
||||
- Includes estimated distance and threat description in alerts
|
||||
|
||||
#### Swedish Government Site Integration
|
||||
The system is pre-configured with coordinates for:
|
||||
- Government offices and Riksdag
|
||||
- Water treatment facilities
|
||||
- Nuclear power plants
|
||||
- Military installations
|
||||
- Major airports
|
||||
|
||||
## Python Simulation Script
|
||||
|
||||
### Swedish Drone Detection Simulator
|
||||
|
||||
The included `drone_simulator.py` script generates realistic drone detection data with Swedish coordinates:
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run basic simulation
|
||||
python drone_simulator.py
|
||||
|
||||
# Custom simulation parameters
|
||||
python drone_simulator.py --devices 10 --detection-interval 30 --duration 7200
|
||||
|
||||
# List available Swedish locations
|
||||
python drone_simulator.py --list-locations
|
||||
```
|
||||
|
||||
### Simulation Features
|
||||
|
||||
- **Realistic RSSI Calculation**: Based on actual distance and path loss
|
||||
- **Threat-Based Scenarios**: Different probability weights for each threat level
|
||||
- **Swedish Coordinates**: Real coordinates for sensitive facilities
|
||||
- **Multiple Device Types**: Government, water, nuclear, military, airport sites
|
||||
- **Continuous Heartbeats**: Device health monitoring
|
||||
- **Battery Simulation**: Realistic battery drain and status changes
|
||||
|
||||
### Threat Scenario Probabilities
|
||||
|
||||
- **Low Threat**: 70% (5-15km range, RSSI -90 to -70 dBm)
|
||||
- **Medium Threat**: 20% (200m-5km range, RSSI -70 to -55 dBm)
|
||||
- **High Threat**: 8% (50-200m range, RSSI -55 to -40 dBm)
|
||||
- **Critical Threat**: 2% (0-50m range, RSSI -40 to -25 dBm)
|
||||
|
||||
## API Enhancements
|
||||
|
||||
### Detection Response Format
|
||||
|
||||
The API now returns threat assessment data:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"device_id": 1941875381,
|
||||
"drone_id": 1001,
|
||||
"rssi": -45,
|
||||
"threat_level": "high",
|
||||
"estimated_distance": 150,
|
||||
"requires_action": true,
|
||||
"geo_lat": 59.3293,
|
||||
"geo_lon": 18.0686,
|
||||
"timestamp": "2025-08-16T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Enhanced Alert Messages
|
||||
|
||||
SMS alerts now include comprehensive threat information:
|
||||
|
||||
```
|
||||
🚨 SECURITY ALERT 🚨
|
||||
THREAT LEVEL: HIGH
|
||||
HIGH THREAT: Drone approaching facility (50-200m)
|
||||
|
||||
📍 LOCATION: Riksdag Stockholm
|
||||
🔧 DEVICE: SecureGuard-001
|
||||
📏 DISTANCE: ~150m
|
||||
📶 SIGNAL: -45 dBm
|
||||
🚁 DRONE TYPE: Professional/Military
|
||||
⏰ TIME: 2025-08-16 10:30:00
|
||||
|
||||
⚠️ IMMEDIATE ACTION REQUIRED
|
||||
Security personnel should respond immediately.
|
||||
```
|
||||
|
||||
## Database Schema Updates
|
||||
|
||||
New fields added to `DroneDetection` model:
|
||||
|
||||
- `threat_level`: ENUM('monitoring', 'low', 'medium', 'high', 'critical')
|
||||
- `estimated_distance`: INTEGER (meters)
|
||||
- `requires_action`: BOOLEAN
|
||||
|
||||
## Security Recommendations
|
||||
|
||||
### For Government Sites
|
||||
- Set `min_threat_level` to "high" for critical facilities
|
||||
- Use multiple alert channels for redundancy
|
||||
- Configure short cooldown periods (2-5 minutes)
|
||||
- Monitor all drone types including consumer drones
|
||||
|
||||
### For Water Facilities
|
||||
- Set `min_threat_level` to "medium" for early warning
|
||||
- Focus on perimeter monitoring (max_distance: 500m)
|
||||
- Longer cooldown periods acceptable (10-15 minutes)
|
||||
|
||||
### For Nuclear Facilities
|
||||
- Set `min_threat_level` to "medium" with escalation to "critical"
|
||||
- Immediate response required for high/critical threats
|
||||
- No cooldown for critical threats
|
||||
- Monitor professional/military drone types with high priority
|
||||
|
||||
### For Military Installations
|
||||
- Maximum security configuration
|
||||
- All threat levels trigger alerts
|
||||
- Multiple redundant alert channels
|
||||
- Real-time monitoring and response protocols
|
||||
379
drone_simulator.py
Normal file
379
drone_simulator.py
Normal file
@@ -0,0 +1,379 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Swedish Drone Detection Simulator
|
||||
|
||||
This script simulates drone detection data for testing the security monitoring system.
|
||||
It generates realistic detection data with Swedish coordinates for government sites,
|
||||
water facilities, and sensitive areas.
|
||||
|
||||
Usage:
|
||||
python drone_simulator.py --help
|
||||
python drone_simulator.py --devices 5 --interval 30 --duration 3600
|
||||
"""
|
||||
|
||||
import requests
|
||||
import time
|
||||
import random
|
||||
import json
|
||||
import argparse
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
from dataclasses import dataclass
|
||||
from typing import List, Tuple
|
||||
import math
|
||||
|
||||
# Swedish coordinates for various sensitive locations
|
||||
SWEDISH_LOCATIONS = {
|
||||
"government_sites": [
|
||||
{"name": "Riksdag Stockholm", "lat": 59.3275, "lon": 18.0644},
|
||||
{"name": "Government Offices Stockholm", "lat": 59.3320, "lon": 18.0648},
|
||||
{"name": "Swedish Armed Forces HQ", "lat": 59.3242, "lon": 18.0969},
|
||||
{"name": "SÄPO Headquarters", "lat": 59.3370, "lon": 18.0585},
|
||||
{"name": "Royal Palace Stockholm", "lat": 59.3267, "lon": 18.0717},
|
||||
],
|
||||
"water_facilities": [
|
||||
{"name": "Norsborg Water Treatment", "lat": 59.2766, "lon": 17.8217},
|
||||
{"name": "Lovö Water Treatment", "lat": 59.3508, "lon": 17.8367},
|
||||
{"name": "Göteborg Water Works", "lat": 57.6950, "lon": 11.9850},
|
||||
{"name": "Malmö Water Treatment", "lat": 55.5833, "lon": 12.9833},
|
||||
{"name": "Uppsala Water Plant", "lat": 59.8586, "lon": 17.6389},
|
||||
],
|
||||
"nuclear_facilities": [
|
||||
{"name": "Forsmark Nuclear Plant", "lat": 60.4017, "lon": 18.1753},
|
||||
{"name": "Oskarshamn Nuclear Plant", "lat": 57.4167, "lon": 16.6667},
|
||||
{"name": "Ringhals Nuclear Plant", "lat": 57.2603, "lon": 12.1086},
|
||||
],
|
||||
"airports": [
|
||||
{"name": "Arlanda Airport", "lat": 59.6519, "lon": 17.9186},
|
||||
{"name": "Landvetter Airport", "lat": 57.6628, "lon": 12.2944},
|
||||
{"name": "Kastrup Airport (Copenhagen)", "lat": 55.6181, "lon": 12.6563},
|
||||
{"name": "Sturup Airport", "lat": 55.5264, "lon": 13.3761},
|
||||
],
|
||||
"military_bases": [
|
||||
{"name": "Karlsborg Fortress", "lat": 58.5342, "lon": 14.5219},
|
||||
{"name": "Boden Fortress", "lat": 65.8250, "lon": 21.6889},
|
||||
{"name": "Gotland Regiment", "lat": 57.6364, "lon": 18.2944},
|
||||
{"name": "Amf 1 Livgardet", "lat": 59.4017, "lon": 17.9439},
|
||||
]
|
||||
}
|
||||
|
||||
@dataclass
|
||||
class DroneDevice:
|
||||
"""Represents a drone detection device"""
|
||||
device_id: int
|
||||
name: str
|
||||
location: str
|
||||
lat: float
|
||||
lon: float
|
||||
category: str
|
||||
last_heartbeat: float = 0
|
||||
battery_level: int = 100
|
||||
signal_strength: int = -45
|
||||
temperature: float = 20.0
|
||||
status: str = "active"
|
||||
|
||||
@dataclass
|
||||
class DroneDetection:
|
||||
"""Represents a drone detection event"""
|
||||
device_id: int
|
||||
geo_lat: float
|
||||
geo_lon: float
|
||||
device_timestamp: int
|
||||
drone_type: int
|
||||
rssi: int
|
||||
freq: int
|
||||
drone_id: int
|
||||
|
||||
class SwedishDroneSimulator:
|
||||
"""Simulates drone detection events for Swedish security installations"""
|
||||
|
||||
def __init__(self, api_base_url: str = "http://localhost:3001/api"):
|
||||
self.api_base_url = api_base_url
|
||||
self.devices: List[DroneDevice] = []
|
||||
self.running = False
|
||||
self.threat_scenarios = {
|
||||
"low_threat": {"probability": 0.7, "rssi_range": (-90, -70), "distance_km": (5, 15)},
|
||||
"medium_threat": {"probability": 0.2, "rssi_range": (-70, -55), "distance_km": (0.2, 5)},
|
||||
"high_threat": {"probability": 0.08, "rssi_range": (-55, -40), "distance_km": (0.05, 0.2)},
|
||||
"critical_threat": {"probability": 0.02, "rssi_range": (-40, -25), "distance_km": (0, 0.05)}
|
||||
}
|
||||
|
||||
def generate_devices(self, num_devices: int) -> List[DroneDevice]:
|
||||
"""Generate drone detection devices at Swedish sensitive locations"""
|
||||
devices = []
|
||||
device_id_base = 1941875380
|
||||
|
||||
all_locations = []
|
||||
for category, locations in SWEDISH_LOCATIONS.items():
|
||||
for loc in locations:
|
||||
all_locations.append((category, loc))
|
||||
|
||||
# Select random locations
|
||||
selected_locations = random.sample(all_locations, min(num_devices, len(all_locations)))
|
||||
|
||||
for i, (category, location) in enumerate(selected_locations):
|
||||
device = DroneDevice(
|
||||
device_id=device_id_base + i + 1,
|
||||
name=f"SecureGuard-{i+1:03d}",
|
||||
location=location["name"],
|
||||
lat=location["lat"],
|
||||
lon=location["lon"],
|
||||
category=category,
|
||||
battery_level=random.randint(75, 100),
|
||||
signal_strength=random.randint(-60, -30),
|
||||
temperature=random.uniform(15, 30)
|
||||
)
|
||||
devices.append(device)
|
||||
|
||||
self.devices = devices
|
||||
return devices
|
||||
|
||||
def calculate_threat_based_rssi(self, distance_km: float, base_rssi: int = -30) -> int:
|
||||
"""Calculate RSSI based on distance for realistic threat simulation"""
|
||||
# Free space path loss formula adapted for drone detection
|
||||
# RSSI = base_rssi - 20*log10(distance_m) - path_loss_factor
|
||||
distance_m = distance_km * 1000
|
||||
if distance_m < 1:
|
||||
distance_m = 1
|
||||
|
||||
path_loss = 20 * math.log10(distance_m) + random.randint(5, 15)
|
||||
rssi = base_rssi - int(path_loss)
|
||||
|
||||
# Clamp to realistic RSSI range
|
||||
return max(-100, min(-25, rssi))
|
||||
|
||||
def generate_detection_near_device(self, device: DroneDevice, threat_level: str = "low_threat") -> DroneDetection:
|
||||
"""Generate a drone detection near a specific device with threat-based parameters"""
|
||||
|
||||
threat_config = self.threat_scenarios[threat_level]
|
||||
|
||||
# Generate distance based on threat level
|
||||
min_dist, max_dist = threat_config["distance_km"]
|
||||
distance_km = random.uniform(min_dist, max_dist)
|
||||
|
||||
# Calculate realistic RSSI based on distance
|
||||
rssi = self.calculate_threat_based_rssi(distance_km)
|
||||
|
||||
# Ensure RSSI is within threat level range
|
||||
rssi_min, rssi_max = threat_config["rssi_range"]
|
||||
rssi = max(rssi_min, min(rssi_max, rssi))
|
||||
|
||||
# Generate coordinates near device (within distance)
|
||||
lat_offset = (random.random() - 0.5) * (distance_km / 111.0) * 2 # ~111km per degree
|
||||
lon_offset = (random.random() - 0.5) * (distance_km / (111.0 * math.cos(math.radians(device.lat)))) * 2
|
||||
|
||||
detection_lat = device.lat + lat_offset
|
||||
detection_lon = device.lon + lon_offset
|
||||
|
||||
# Drone type based on threat level
|
||||
if threat_level == "critical_threat":
|
||||
drone_type = 1 # Professional/Military
|
||||
elif threat_level == "high_threat":
|
||||
drone_type = random.choice([1, 2]) # Professional or Racing
|
||||
else:
|
||||
drone_type = random.choice([0, 0, 0, 1, 2]) # Mostly consumer
|
||||
|
||||
return DroneDetection(
|
||||
device_id=device.device_id,
|
||||
geo_lat=round(detection_lat, 6),
|
||||
geo_lon=round(detection_lon, 6),
|
||||
device_timestamp=int(time.time()),
|
||||
drone_type=drone_type,
|
||||
rssi=rssi,
|
||||
freq=random.choice([20, 22, 24, 25, 27, 58, 915, 2400, 2450, 5800]), # Common drone frequencies
|
||||
drone_id=random.randint(1000, 9999)
|
||||
)
|
||||
|
||||
def select_threat_level(self) -> str:
|
||||
"""Select threat level based on probability weights for realistic scenarios"""
|
||||
rand = random.random()
|
||||
cumulative = 0
|
||||
|
||||
for threat_level, config in self.threat_scenarios.items():
|
||||
cumulative += config["probability"]
|
||||
if rand <= cumulative:
|
||||
return threat_level
|
||||
|
||||
return "low_threat"
|
||||
|
||||
def send_detection(self, detection: DroneDetection) -> bool:
|
||||
"""Send drone detection to the API"""
|
||||
try:
|
||||
payload = {
|
||||
"device_id": detection.device_id,
|
||||
"geo_lat": detection.geo_lat,
|
||||
"geo_lon": detection.geo_lon,
|
||||
"device_timestamp": detection.device_timestamp,
|
||||
"drone_type": detection.drone_type,
|
||||
"rssi": detection.rssi,
|
||||
"freq": detection.freq,
|
||||
"drone_id": detection.drone_id
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.api_base_url}/detections",
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
threat_level = "CRITICAL" if detection.rssi >= -40 else "HIGH" if detection.rssi >= -55 else "MEDIUM" if detection.rssi >= -70 else "LOW"
|
||||
device = next((d for d in self.devices if d.device_id == detection.device_id), None)
|
||||
location_name = device.location if device else "Unknown"
|
||||
|
||||
print(f"🚨 {threat_level} THREAT DETECTION:")
|
||||
print(f" Location: {location_name}")
|
||||
print(f" Device: {detection.device_id}")
|
||||
print(f" RSSI: {detection.rssi} dBm")
|
||||
print(f" Drone Type: {detection.drone_type}")
|
||||
print(f" Est. Distance: ~{round(10**(((-30) - detection.rssi) / (10 * 3)))}m")
|
||||
print(f" Coordinates: {detection.geo_lat}, {detection.geo_lon}")
|
||||
print()
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Failed to send detection: {response.status_code} - {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error sending detection: {e}")
|
||||
return False
|
||||
|
||||
def send_heartbeat(self, device: DroneDevice) -> bool:
|
||||
"""Send device heartbeat to the API"""
|
||||
try:
|
||||
# Simulate device status changes
|
||||
if random.random() < 0.05: # 5% chance of status change
|
||||
device.battery_level = max(10, device.battery_level - random.randint(1, 5))
|
||||
device.signal_strength = random.randint(-65, -25)
|
||||
device.temperature = random.uniform(10, 35)
|
||||
|
||||
payload = {
|
||||
"type": "heartbeat",
|
||||
"key": f"device_{device.device_id}_key",
|
||||
"battery_level": device.battery_level,
|
||||
"signal_strength": device.signal_strength,
|
||||
"temperature": device.temperature
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
f"{self.api_base_url}/heartbeat",
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
device.last_heartbeat = time.time()
|
||||
status_icon = "🟢" if device.battery_level > 30 else "🟡" if device.battery_level > 15 else "🔴"
|
||||
print(f"{status_icon} Heartbeat: {device.name} ({device.location}) - Battery: {device.battery_level}%")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Failed to send heartbeat for device {device.device_id}: {response.status_code}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error sending heartbeat for device {device.device_id}: {e}")
|
||||
return False
|
||||
|
||||
def run_simulation(self, detection_interval: int = 60, heartbeat_interval: int = 30, duration: int = 3600):
|
||||
"""Run the complete simulation"""
|
||||
print("🇸🇪 Swedish Drone Detection Security Simulator")
|
||||
print("=" * 50)
|
||||
print(f"📊 Simulation Parameters:")
|
||||
print(f" • Devices: {len(self.devices)}")
|
||||
print(f" • Detection interval: {detection_interval}s")
|
||||
print(f" • Heartbeat interval: {heartbeat_interval}s")
|
||||
print(f" • Duration: {duration}s ({duration//60} minutes)")
|
||||
print(f" • API Base URL: {self.api_base_url}")
|
||||
print()
|
||||
|
||||
# Display device locations
|
||||
print("📍 Monitoring Locations:")
|
||||
for device in self.devices:
|
||||
print(f" • {device.name}: {device.location} ({device.category.replace('_', ' ').title()})")
|
||||
print()
|
||||
|
||||
self.running = True
|
||||
start_time = time.time()
|
||||
last_detection_time = 0
|
||||
last_heartbeat_time = 0
|
||||
|
||||
print("🚀 Starting simulation...\n")
|
||||
|
||||
try:
|
||||
while self.running and (time.time() - start_time) < duration:
|
||||
current_time = time.time()
|
||||
|
||||
# Send heartbeats
|
||||
if current_time - last_heartbeat_time >= heartbeat_interval:
|
||||
for device in self.devices:
|
||||
self.send_heartbeat(device)
|
||||
last_heartbeat_time = current_time
|
||||
print()
|
||||
|
||||
# Generate detections
|
||||
if current_time - last_detection_time >= detection_interval:
|
||||
# Simulate detection probability (not every interval)
|
||||
if random.random() < 0.4: # 40% chance of detection
|
||||
device = random.choice(self.devices)
|
||||
threat_level = self.select_threat_level()
|
||||
detection = self.generate_detection_near_device(device, threat_level)
|
||||
self.send_detection(detection)
|
||||
|
||||
last_detection_time = current_time
|
||||
|
||||
# Sleep for a short interval
|
||||
time.sleep(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n🛑 Simulation stopped by user")
|
||||
|
||||
print(f"\n✅ Simulation completed. Duration: {int(time.time() - start_time)}s")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Swedish Drone Detection Security Simulator")
|
||||
parser.add_argument("--devices", type=int, default=5, help="Number of devices to simulate (default: 5)")
|
||||
parser.add_argument("--detection-interval", type=int, default=60, help="Detection interval in seconds (default: 60)")
|
||||
parser.add_argument("--heartbeat-interval", type=int, default=30, help="Heartbeat interval in seconds (default: 30)")
|
||||
parser.add_argument("--duration", type=int, default=3600, help="Simulation duration in seconds (default: 3600)")
|
||||
parser.add_argument("--api-url", default="http://localhost:3001/api", help="API base URL (default: http://localhost:3001/api)")
|
||||
parser.add_argument("--list-locations", action="store_true", help="List all available Swedish locations and exit")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list_locations:
|
||||
print("🇸🇪 Available Swedish Security Monitoring Locations:")
|
||||
print("=" * 60)
|
||||
for category, locations in SWEDISH_LOCATIONS.items():
|
||||
print(f"\n{category.replace('_', ' ').title()}:")
|
||||
for i, loc in enumerate(locations, 1):
|
||||
print(f" {i}. {loc['name']} ({loc['lat']}, {loc['lon']})")
|
||||
return
|
||||
|
||||
# Initialize simulator
|
||||
simulator = SwedishDroneSimulator(args.api_url)
|
||||
|
||||
# Generate devices
|
||||
devices = simulator.generate_devices(args.devices)
|
||||
|
||||
# Test API connectivity
|
||||
try:
|
||||
response = requests.get(f"{args.api_url}/dashboard/stats", timeout=5)
|
||||
if response.status_code != 200:
|
||||
print(f"⚠️ Warning: API test failed ({response.status_code}). Proceeding anyway...")
|
||||
except Exception as e:
|
||||
print(f"⚠️ Warning: Could not connect to API at {args.api_url}")
|
||||
print(f" Make sure the backend server is running!")
|
||||
print(f" Error: {e}")
|
||||
print()
|
||||
|
||||
# Run simulation
|
||||
simulator.run_simulation(
|
||||
detection_interval=args.detection_interval,
|
||||
heartbeat_interval=args.heartbeat_interval,
|
||||
duration=args.duration
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
37
package.json
Normal file
37
package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "drone-detection-system",
|
||||
"version": "1.0.0",
|
||||
"description": "Real-time drone detection and monitoring system",
|
||||
"main": "server/index.js",
|
||||
"scripts": {
|
||||
"install:all": "npm install && cd server && npm install && cd ../client && npm install && cd ..",
|
||||
"install:server": "cd server && npm install",
|
||||
"install:client": "cd client && npm install",
|
||||
"dev": "concurrently \"npm run server:dev\" \"npm run client:dev\"",
|
||||
"server:dev": "cd server && npm run dev",
|
||||
"server:start": "cd server && npm start",
|
||||
"client:dev": "cd client && npm run dev",
|
||||
"client:build": "cd client && npm run build",
|
||||
"client:preview": "cd client && npm run preview",
|
||||
"db:setup": "cd server && node scripts/setup-database.js",
|
||||
"db:reset": "cd server && node scripts/setup-database.js",
|
||||
"build": "npm run client:build",
|
||||
"start": "npm run server:start",
|
||||
"setup": "npm run install:all && npm run db:setup",
|
||||
"docker:build": "docker-compose build",
|
||||
"docker:up": "docker-compose up -d",
|
||||
"docker:down": "docker-compose down",
|
||||
"docker:logs": "docker-compose logs -f",
|
||||
"docker:restart": "docker-compose restart",
|
||||
"docker:clean": "docker-compose down -v && docker system prune -f",
|
||||
"docker:prod": "docker-compose --profile production up -d",
|
||||
"docker:simulate": "docker-compose --profile simulation up -d",
|
||||
"docker:backup": "docker-compose exec postgres pg_dump -U postgres drone_detection > backup-$(date +%Y%m%d-%H%M%S).sql"
|
||||
},
|
||||
"keywords": ["drone", "detection", "monitoring", "iot", "security"],
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"concurrently": "^7.6.0"
|
||||
}
|
||||
}
|
||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests>=2.28.0
|
||||
102
server/.dockerignore
Normal file
102
server/.dockerignore
Normal file
@@ -0,0 +1,102 @@
|
||||
# Node.js
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage/
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Microbundle cache
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
# nuxt.js build output
|
||||
.nuxt
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Development files
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
29
server/.env.example
Normal file
29
server/.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# Environment Configuration
|
||||
NODE_ENV=development
|
||||
PORT=3001
|
||||
|
||||
# Database Configuration
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_NAME=drone_detection
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=password
|
||||
|
||||
# JWT Secret
|
||||
JWT_SECRET=your-super-secret-jwt-key-change-this-in-production
|
||||
|
||||
# Twilio Configuration (for SMS alerts)
|
||||
TWILIO_ACCOUNT_SID=your-twilio-account-sid
|
||||
TWILIO_AUTH_TOKEN=your-twilio-auth-token
|
||||
TWILIO_PHONE_NUMBER=your-twilio-phone-number
|
||||
|
||||
# Alert Configuration
|
||||
SMS_ALERTS_ENABLED=true
|
||||
EMAIL_ALERTS_ENABLED=false
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX_REQUESTS=100
|
||||
|
||||
# CORS Configuration
|
||||
CORS_ORIGIN=http://localhost:3000
|
||||
49
server/Dockerfile
Normal file
49
server/Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Backend Dockerfile for Drone Detection System
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install system dependencies
|
||||
RUN apk add --no-cache \
|
||||
python3 \
|
||||
make \
|
||||
g++ \
|
||||
curl \
|
||||
dumb-init
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package*.json ./
|
||||
|
||||
# Install dependencies
|
||||
RUN npm ci --only=production && \
|
||||
npm cache clean --force
|
||||
|
||||
# Copy application code
|
||||
COPY . .
|
||||
|
||||
# Create logs directory
|
||||
RUN mkdir -p logs
|
||||
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S nodejs && \
|
||||
adduser -S nodejs -u 1001
|
||||
|
||||
# Set ownership
|
||||
RUN chown -R nodejs:nodejs /app
|
||||
|
||||
# Switch to non-root user
|
||||
USER nodejs
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3001
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD curl -f http://localhost:3001/api/health || exit 1
|
||||
|
||||
# Use dumb-init to handle signals properly
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
95
server/index.js
Normal file
95
server/index.js
Normal file
@@ -0,0 +1,95 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const helmet = require('helmet');
|
||||
const morgan = require('morgan');
|
||||
const compression = require('compression');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const { createServer } = require('http');
|
||||
const { Server } = require('socket.io');
|
||||
require('dotenv').config();
|
||||
|
||||
const { sequelize } = require('./models');
|
||||
const routes = require('./routes');
|
||||
const { initializeSocketHandlers } = require('./services/socketService');
|
||||
const errorHandler = require('./middleware/errorHandler');
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
||||
methods: ["GET", "POST", "PUT", "DELETE"]
|
||||
}
|
||||
});
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
||||
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 100,
|
||||
message: 'Too many requests from this IP, please try again later.'
|
||||
});
|
||||
|
||||
// Middleware
|
||||
app.use(helmet());
|
||||
app.use(compression());
|
||||
app.use(morgan('combined'));
|
||||
app.use(cors({
|
||||
origin: process.env.CORS_ORIGIN || "http://localhost:3000",
|
||||
credentials: true
|
||||
}));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Make io available to routes
|
||||
app.use((req, res, next) => {
|
||||
req.io = io;
|
||||
next();
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api', routes);
|
||||
|
||||
// Health check endpoints
|
||||
app.get('/health', (req, res) => {
|
||||
res.status(200).json({
|
||||
status: 'OK',
|
||||
timestamp: new Date().toISOString(),
|
||||
environment: process.env.NODE_ENV
|
||||
});
|
||||
});
|
||||
|
||||
app.use('/api/health', require('./routes/health'));
|
||||
|
||||
// Error handling
|
||||
app.use(errorHandler);
|
||||
|
||||
// Socket.IO initialization
|
||||
initializeSocketHandlers(io);
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Database connection and server startup
|
||||
async function startServer() {
|
||||
try {
|
||||
await sequelize.authenticate();
|
||||
console.log('Database connected successfully.');
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
await sequelize.sync({ alter: true });
|
||||
console.log('Database synchronized.');
|
||||
}
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
console.log(`Environment: ${process.env.NODE_ENV}`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Unable to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
startServer();
|
||||
|
||||
module.exports = { app, server, io };
|
||||
63
server/middleware/auth.js
Normal file
63
server/middleware/auth.js
Normal file
@@ -0,0 +1,63 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { User } = require('../models');
|
||||
|
||||
async function authenticateToken(req, res, next) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Access token required'
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||
const user = await User.findByPk(decoded.userId, {
|
||||
attributes: ['id', 'username', 'email', 'role', 'is_active']
|
||||
});
|
||||
|
||||
if (!user || !user.is_active) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid or inactive user'
|
||||
});
|
||||
}
|
||||
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (error) {
|
||||
console.error('Token verification error:', error);
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function requireRole(roles) {
|
||||
return (req, res, next) => {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
const userRoles = Array.isArray(roles) ? roles : [roles];
|
||||
if (!userRoles.includes(req.user.role)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
message: 'Insufficient permissions'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
authenticateToken,
|
||||
requireRole
|
||||
};
|
||||
57
server/middleware/errorHandler.js
Normal file
57
server/middleware/errorHandler.js
Normal file
@@ -0,0 +1,57 @@
|
||||
function errorHandler(error, req, res, next) {
|
||||
console.error('Error occurred:', error);
|
||||
|
||||
// Default error response
|
||||
let statusCode = 500;
|
||||
let message = 'Internal server error';
|
||||
let details = null;
|
||||
|
||||
// Handle specific error types
|
||||
if (error.name === 'ValidationError') {
|
||||
statusCode = 400;
|
||||
message = 'Validation error';
|
||||
details = error.details || error.message;
|
||||
} else if (error.name === 'UnauthorizedError') {
|
||||
statusCode = 401;
|
||||
message = 'Unauthorized';
|
||||
} else if (error.name === 'SequelizeValidationError') {
|
||||
statusCode = 400;
|
||||
message = 'Database validation error';
|
||||
details = error.errors.map(err => ({
|
||||
field: err.path,
|
||||
message: err.message
|
||||
}));
|
||||
} else if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
statusCode = 409;
|
||||
message = 'Resource already exists';
|
||||
details = error.errors.map(err => ({
|
||||
field: err.path,
|
||||
message: err.message
|
||||
}));
|
||||
} else if (error.name === 'SequelizeForeignKeyConstraintError') {
|
||||
statusCode = 400;
|
||||
message = 'Invalid reference';
|
||||
} else if (error.status) {
|
||||
statusCode = error.status;
|
||||
message = error.message;
|
||||
}
|
||||
|
||||
// Send error response
|
||||
const response = {
|
||||
success: false,
|
||||
message,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Include error details in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
response.error = details || error.message;
|
||||
response.stack = error.stack;
|
||||
} else if (details) {
|
||||
response.details = details;
|
||||
}
|
||||
|
||||
res.status(statusCode).json(response);
|
||||
}
|
||||
|
||||
module.exports = errorHandler;
|
||||
30
server/middleware/validation.js
Normal file
30
server/middleware/validation.js
Normal file
@@ -0,0 +1,30 @@
|
||||
function validateRequest(schema) {
|
||||
return (req, res, next) => {
|
||||
const { error, value } = schema.validate(req.body, {
|
||||
abortEarly: false,
|
||||
stripUnknown: true
|
||||
});
|
||||
|
||||
if (error) {
|
||||
const errorDetails = error.details.map(detail => ({
|
||||
field: detail.path.join('.'),
|
||||
message: detail.message,
|
||||
value: detail.context.value
|
||||
}));
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: 'Validation error',
|
||||
errors: errorDetails
|
||||
});
|
||||
}
|
||||
|
||||
// Replace req.body with validated and sanitized data
|
||||
req.body = value;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
validateRequest
|
||||
};
|
||||
109
server/models/AlertLog.js
Normal file
109
server/models/AlertLog.js
Normal file
@@ -0,0 +1,109 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const AlertLog = sequelize.define('AlertLog', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
alert_rule_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'alert_rules',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
detection_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'drone_detections',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
alert_type: {
|
||||
type: DataTypes.ENUM('sms', 'email', 'webhook', 'push'),
|
||||
allowNull: false
|
||||
},
|
||||
recipient: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Phone number, email, or webhook URL'
|
||||
},
|
||||
message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: false,
|
||||
comment: 'Alert message content'
|
||||
},
|
||||
status: {
|
||||
type: DataTypes.ENUM('pending', 'sent', 'failed', 'delivered'),
|
||||
defaultValue: 'pending'
|
||||
},
|
||||
sent_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
delivered_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
error_message: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Error message if alert failed'
|
||||
},
|
||||
external_id: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'External service message ID (Twilio SID, etc.)'
|
||||
},
|
||||
cost: {
|
||||
type: DataTypes.DECIMAL(6, 4),
|
||||
allowNull: true,
|
||||
comment: 'Cost of sending the alert (for SMS, etc.)'
|
||||
},
|
||||
retry_count: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 0,
|
||||
comment: 'Number of retry attempts'
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.ENUM('low', 'medium', 'high', 'critical'),
|
||||
defaultValue: 'medium'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'alert_logs',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['alert_rule_id']
|
||||
},
|
||||
{
|
||||
fields: ['detection_id']
|
||||
},
|
||||
{
|
||||
fields: ['status']
|
||||
},
|
||||
{
|
||||
fields: ['sent_at']
|
||||
},
|
||||
{
|
||||
fields: ['alert_type', 'status']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return AlertLog;
|
||||
};
|
||||
124
server/models/AlertRule.js
Normal file
124
server/models/AlertRule.js
Normal file
@@ -0,0 +1,124 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const AlertRule = sequelize.define('AlertRule', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
user_id: {
|
||||
type: DataTypes.UUID,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'users',
|
||||
key: 'id'
|
||||
}
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Human-readable name for the alert rule'
|
||||
},
|
||||
description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Description of what triggers this alert'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
device_ids: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of device IDs to monitor (null = all devices)'
|
||||
},
|
||||
drone_types: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of drone types to alert on (null = all types)'
|
||||
},
|
||||
min_rssi: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Minimum RSSI threshold for alert'
|
||||
},
|
||||
max_rssi: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Maximum RSSI threshold for alert'
|
||||
},
|
||||
frequency_ranges: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Array of frequency ranges to monitor [{min: 20, max: 30}]'
|
||||
},
|
||||
time_window: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 300,
|
||||
comment: 'Time window in seconds to group detections'
|
||||
},
|
||||
min_detections: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 1,
|
||||
comment: 'Minimum number of detections in time window to trigger alert'
|
||||
},
|
||||
cooldown_period: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 600,
|
||||
comment: 'Cooldown period in seconds between alerts for same drone'
|
||||
},
|
||||
alert_channels: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: ['sms'],
|
||||
comment: 'Array of alert channels: sms, email, webhook'
|
||||
},
|
||||
webhook_url: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Webhook URL for custom integrations'
|
||||
},
|
||||
active_hours: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
comment: 'Active hours for alerts {start: "09:00", end: "17:00"}'
|
||||
},
|
||||
active_days: {
|
||||
type: DataTypes.JSON,
|
||||
defaultValue: [1, 2, 3, 4, 5, 6, 7],
|
||||
comment: 'Active days of week (1=Monday, 7=Sunday)'
|
||||
},
|
||||
priority: {
|
||||
type: DataTypes.ENUM('low', 'medium', 'high', 'critical'),
|
||||
defaultValue: 'medium',
|
||||
comment: 'Alert priority level'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'alert_rules',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['user_id']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
},
|
||||
{
|
||||
fields: ['priority']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return AlertRule;
|
||||
};
|
||||
88
server/models/Device.js
Normal file
88
server/models/Device.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Device = sequelize.define('Device', {
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
allowNull: false,
|
||||
comment: 'Unique device identifier from hardware'
|
||||
},
|
||||
name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Human-readable device name'
|
||||
},
|
||||
geo_lat: {
|
||||
type: DataTypes.DECIMAL(10, 8),
|
||||
allowNull: true,
|
||||
comment: 'Device latitude coordinate'
|
||||
},
|
||||
geo_lon: {
|
||||
type: DataTypes.DECIMAL(11, 8),
|
||||
allowNull: true,
|
||||
comment: 'Device longitude coordinate'
|
||||
},
|
||||
location_description: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Human-readable location description'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: 'Whether the device is currently active'
|
||||
},
|
||||
last_heartbeat: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'Timestamp of last heartbeat received'
|
||||
},
|
||||
heartbeat_interval: {
|
||||
type: DataTypes.INTEGER,
|
||||
defaultValue: 300, // 5 minutes
|
||||
comment: 'Expected heartbeat interval in seconds'
|
||||
},
|
||||
firmware_version: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Device firmware version'
|
||||
},
|
||||
installation_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true,
|
||||
comment: 'When the device was installed'
|
||||
},
|
||||
notes: {
|
||||
type: DataTypes.TEXT,
|
||||
allowNull: true,
|
||||
comment: 'Additional notes about the device'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'devices',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['geo_lat', 'geo_lon']
|
||||
},
|
||||
{
|
||||
fields: ['last_heartbeat']
|
||||
},
|
||||
{
|
||||
fields: ['is_active']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return Device;
|
||||
};
|
||||
120
server/models/DroneDetection.js
Normal file
120
server/models/DroneDetection.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const DroneDetection = sequelize.define('DroneDetection', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
device_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'ID of the detecting device'
|
||||
},
|
||||
drone_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: 'Detected drone identifier'
|
||||
},
|
||||
drone_type: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Type of drone detected (0=unknown, 1=commercial, 2=racing, etc.)'
|
||||
},
|
||||
rssi: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0,
|
||||
comment: 'Received Signal Strength Indicator'
|
||||
},
|
||||
freq: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
comment: 'Frequency detected'
|
||||
},
|
||||
geo_lat: {
|
||||
type: DataTypes.DECIMAL(10, 8),
|
||||
allowNull: true,
|
||||
comment: 'Latitude where detection occurred'
|
||||
},
|
||||
geo_lon: {
|
||||
type: DataTypes.DECIMAL(11, 8),
|
||||
allowNull: true,
|
||||
comment: 'Longitude where detection occurred'
|
||||
},
|
||||
device_timestamp: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
comment: 'Unix timestamp from the device'
|
||||
},
|
||||
server_timestamp: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: 'When the detection was received by server'
|
||||
},
|
||||
confidence_level: {
|
||||
type: DataTypes.DECIMAL(3, 2),
|
||||
allowNull: true,
|
||||
comment: 'Confidence level of detection (0.00-1.00)'
|
||||
},
|
||||
signal_duration: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Duration of signal in milliseconds'
|
||||
},
|
||||
processed: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether this detection has been processed for alerts'
|
||||
},
|
||||
threat_level: {
|
||||
type: DataTypes.ENUM('monitoring', 'low', 'medium', 'high', 'critical'),
|
||||
allowNull: true,
|
||||
comment: 'Assessed threat level based on RSSI and drone type'
|
||||
},
|
||||
estimated_distance: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Estimated distance to drone in meters'
|
||||
},
|
||||
requires_action: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether this detection requires immediate security action'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'drone_detections',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['device_id']
|
||||
},
|
||||
{
|
||||
fields: ['drone_id']
|
||||
},
|
||||
{
|
||||
fields: ['server_timestamp']
|
||||
},
|
||||
{
|
||||
fields: ['processed']
|
||||
},
|
||||
{
|
||||
fields: ['device_id', 'drone_id', 'server_timestamp']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return DroneDetection;
|
||||
};
|
||||
82
server/models/Heartbeat.js
Normal file
82
server/models/Heartbeat.js
Normal file
@@ -0,0 +1,82 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const Heartbeat = sequelize.define('Heartbeat', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
device_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'devices',
|
||||
key: 'id'
|
||||
},
|
||||
comment: 'ID of the device sending heartbeat'
|
||||
},
|
||||
device_key: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
comment: 'Unique key of the sensor from heartbeat message'
|
||||
},
|
||||
signal_strength: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Signal strength at time of heartbeat'
|
||||
},
|
||||
battery_level: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Battery level percentage (0-100)'
|
||||
},
|
||||
temperature: {
|
||||
type: DataTypes.DECIMAL(4, 1),
|
||||
allowNull: true,
|
||||
comment: 'Device temperature in Celsius'
|
||||
},
|
||||
uptime: {
|
||||
type: DataTypes.BIGINT,
|
||||
allowNull: true,
|
||||
comment: 'Device uptime in seconds'
|
||||
},
|
||||
memory_usage: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: true,
|
||||
comment: 'Memory usage percentage'
|
||||
},
|
||||
firmware_version: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Firmware version reported in heartbeat'
|
||||
},
|
||||
received_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW,
|
||||
comment: 'When heartbeat was received by server'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'heartbeats',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: false,
|
||||
indexes: [
|
||||
{
|
||||
fields: ['device_id']
|
||||
},
|
||||
{
|
||||
fields: ['received_at']
|
||||
},
|
||||
{
|
||||
fields: ['device_id', 'received_at']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return Heartbeat;
|
||||
};
|
||||
98
server/models/User.js
Normal file
98
server/models/User.js
Normal file
@@ -0,0 +1,98 @@
|
||||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const User = sequelize.define('User', {
|
||||
id: {
|
||||
type: DataTypes.UUID,
|
||||
defaultValue: DataTypes.UUIDV4,
|
||||
primaryKey: true
|
||||
},
|
||||
username: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
len: [3, 50]
|
||||
}
|
||||
},
|
||||
email: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false,
|
||||
unique: true,
|
||||
validate: {
|
||||
isEmail: true
|
||||
}
|
||||
},
|
||||
password_hash: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: false
|
||||
},
|
||||
first_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
last_name: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true
|
||||
},
|
||||
phone_number: {
|
||||
type: DataTypes.STRING,
|
||||
allowNull: true,
|
||||
comment: 'Phone number for SMS alerts (include country code)'
|
||||
},
|
||||
role: {
|
||||
type: DataTypes.ENUM('admin', 'operator', 'viewer'),
|
||||
defaultValue: 'viewer',
|
||||
comment: 'User role for permission management'
|
||||
},
|
||||
is_active: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true
|
||||
},
|
||||
sms_alerts_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
comment: 'Whether user wants to receive SMS alerts'
|
||||
},
|
||||
email_alerts_enabled: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: true,
|
||||
comment: 'Whether user wants to receive email alerts'
|
||||
},
|
||||
last_login: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: true
|
||||
},
|
||||
timezone: {
|
||||
type: DataTypes.STRING,
|
||||
defaultValue: 'UTC',
|
||||
comment: 'User timezone for alert scheduling'
|
||||
},
|
||||
created_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
},
|
||||
updated_at: {
|
||||
type: DataTypes.DATE,
|
||||
defaultValue: DataTypes.NOW
|
||||
}
|
||||
}, {
|
||||
tableName: 'users',
|
||||
timestamps: true,
|
||||
createdAt: 'created_at',
|
||||
updatedAt: 'updated_at',
|
||||
indexes: [
|
||||
{
|
||||
fields: ['email']
|
||||
},
|
||||
{
|
||||
fields: ['username']
|
||||
},
|
||||
{
|
||||
fields: ['phone_number']
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
return User;
|
||||
};
|
||||
54
server/models/index.js
Normal file
54
server/models/index.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
require('dotenv').config();
|
||||
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME || 'drone_detection',
|
||||
process.env.DB_USER || 'postgres',
|
||||
process.env.DB_PASSWORD || 'password',
|
||||
{
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: process.env.DB_PORT || 5432,
|
||||
dialect: 'postgres',
|
||||
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||
pool: {
|
||||
max: 5,
|
||||
min: 0,
|
||||
acquire: 30000,
|
||||
idle: 10000
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Import models
|
||||
const Device = require('./Device')(sequelize);
|
||||
const DroneDetection = require('./DroneDetection')(sequelize);
|
||||
const Heartbeat = require('./Heartbeat')(sequelize);
|
||||
const User = require('./User')(sequelize);
|
||||
const AlertRule = require('./AlertRule')(sequelize);
|
||||
const AlertLog = require('./AlertLog')(sequelize);
|
||||
|
||||
// Define associations
|
||||
Device.hasMany(DroneDetection, { foreignKey: 'device_id', as: 'detections' });
|
||||
DroneDetection.belongsTo(Device, { foreignKey: 'device_id', as: 'device' });
|
||||
|
||||
Device.hasMany(Heartbeat, { foreignKey: 'device_id', as: 'heartbeats' });
|
||||
Heartbeat.belongsTo(Device, { foreignKey: 'device_id', as: 'device' });
|
||||
|
||||
User.hasMany(AlertRule, { foreignKey: 'user_id', as: 'alertRules' });
|
||||
AlertRule.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
|
||||
AlertRule.hasMany(AlertLog, { foreignKey: 'alert_rule_id', as: 'logs' });
|
||||
AlertLog.belongsTo(AlertRule, { foreignKey: 'alert_rule_id', as: 'rule' });
|
||||
|
||||
DroneDetection.hasMany(AlertLog, { foreignKey: 'detection_id', as: 'alerts' });
|
||||
AlertLog.belongsTo(DroneDetection, { foreignKey: 'detection_id', as: 'detection' });
|
||||
|
||||
module.exports = {
|
||||
sequelize,
|
||||
Device,
|
||||
DroneDetection,
|
||||
Heartbeat,
|
||||
User,
|
||||
AlertRule,
|
||||
AlertLog
|
||||
};
|
||||
35
server/package.json
Normal file
35
server/package.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "drone-detection-server",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend API for drone detection system",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"db:setup": "node scripts/setup-database.js",
|
||||
"db:migrate": "node scripts/migrate.js",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5",
|
||||
"helmet": "^7.0.0",
|
||||
"morgan": "^1.10.0",
|
||||
"dotenv": "^16.3.1",
|
||||
"pg": "^8.11.3",
|
||||
"pg-hstore": "^2.3.4",
|
||||
"sequelize": "^6.32.1",
|
||||
"socket.io": "^4.7.2",
|
||||
"twilio": "^4.14.0",
|
||||
"joi": "^17.9.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"jsonwebtoken": "^9.0.1",
|
||||
"express-rate-limit": "^6.8.1",
|
||||
"compression": "^1.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.1",
|
||||
"jest": "^29.6.1",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
311
server/routes/alert.js
Normal file
311
server/routes/alert.js
Normal file
@@ -0,0 +1,311 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Joi = require('joi');
|
||||
const { AlertRule, AlertLog, User } = require('../models');
|
||||
const { validateRequest } = require('../middleware/validation');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// Validation schemas
|
||||
const alertRuleSchema = Joi.object({
|
||||
name: Joi.string().required(),
|
||||
description: Joi.string().optional(),
|
||||
device_ids: Joi.array().items(Joi.number().integer()).optional(),
|
||||
drone_types: Joi.array().items(Joi.number().integer()).optional(),
|
||||
min_rssi: Joi.number().integer().optional(),
|
||||
max_rssi: Joi.number().integer().optional(),
|
||||
frequency_ranges: Joi.array().items(Joi.object({
|
||||
min: Joi.number().integer().required(),
|
||||
max: Joi.number().integer().required()
|
||||
})).optional(),
|
||||
time_window: Joi.number().integer().min(60).max(3600).default(300),
|
||||
min_detections: Joi.number().integer().min(1).default(1),
|
||||
cooldown_period: Joi.number().integer().min(0).default(600),
|
||||
alert_channels: Joi.array().items(Joi.string().valid('sms', 'email', 'webhook')).default(['sms']),
|
||||
webhook_url: Joi.string().uri().optional(),
|
||||
active_hours: Joi.object({
|
||||
start: Joi.string().pattern(/^\d{2}:\d{2}$/).optional(),
|
||||
end: Joi.string().pattern(/^\d{2}:\d{2}$/).optional()
|
||||
}).optional(),
|
||||
active_days: Joi.array().items(Joi.number().integer().min(1).max(7)).default([1,2,3,4,5,6,7]),
|
||||
priority: Joi.string().valid('low', 'medium', 'high', 'critical').default('medium')
|
||||
});
|
||||
|
||||
// GET /api/alerts/rules - Get alert rules for current user
|
||||
router.get('/rules', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0, is_active } = req.query;
|
||||
|
||||
const whereClause = { user_id: req.user.id };
|
||||
if (is_active !== undefined) whereClause.is_active = is_active === 'true';
|
||||
|
||||
const alertRules = await AlertRule.findAndCountAll({
|
||||
where: whereClause,
|
||||
limit: Math.min(parseInt(limit), 100),
|
||||
offset: parseInt(offset),
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: alertRules.rows,
|
||||
pagination: {
|
||||
total: alertRules.count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
pages: Math.ceil(alertRules.count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching alert rules:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch alert rules',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/alerts/rules - Create new alert rule
|
||||
router.post('/rules', authenticateToken, validateRequest(alertRuleSchema), async (req, res) => {
|
||||
try {
|
||||
const alertRule = await AlertRule.create({
|
||||
...req.body,
|
||||
user_id: req.user.id
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: alertRule,
|
||||
message: 'Alert rule created successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating alert rule:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create alert rule',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/alerts/rules/:id - Update alert rule
|
||||
router.put('/rules/:id', authenticateToken, validateRequest(alertRuleSchema), async (req, res) => {
|
||||
try {
|
||||
const alertRule = await AlertRule.findOne({
|
||||
where: {
|
||||
id: req.params.id,
|
||||
user_id: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!alertRule) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Alert rule not found'
|
||||
});
|
||||
}
|
||||
|
||||
await alertRule.update(req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: alertRule,
|
||||
message: 'Alert rule updated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating alert rule:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update alert rule',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/alerts/rules/:id - Delete alert rule
|
||||
router.delete('/rules/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const alertRule = await AlertRule.findOne({
|
||||
where: {
|
||||
id: req.params.id,
|
||||
user_id: req.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!alertRule) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Alert rule not found'
|
||||
});
|
||||
}
|
||||
|
||||
await alertRule.destroy();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Alert rule deleted successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting alert rule:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete alert rule',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/alerts/logs - Get alert logs for current user
|
||||
router.get('/logs', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
status,
|
||||
alert_type,
|
||||
start_date,
|
||||
end_date
|
||||
} = req.query;
|
||||
|
||||
const whereClause = {};
|
||||
if (status) whereClause.status = status;
|
||||
if (alert_type) whereClause.alert_type = alert_type;
|
||||
|
||||
if (start_date || end_date) {
|
||||
whereClause.created_at = {};
|
||||
if (start_date) whereClause.created_at[Op.gte] = new Date(start_date);
|
||||
if (end_date) whereClause.created_at[Op.lte] = new Date(end_date);
|
||||
}
|
||||
|
||||
const alertLogs = await AlertLog.findAndCountAll({
|
||||
where: whereClause,
|
||||
include: [{
|
||||
model: AlertRule,
|
||||
as: 'rule',
|
||||
where: { user_id: req.user.id },
|
||||
attributes: ['id', 'name', 'priority']
|
||||
}],
|
||||
limit: Math.min(parseInt(limit), 200),
|
||||
offset: parseInt(offset),
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: alertLogs.rows,
|
||||
pagination: {
|
||||
total: alertLogs.count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
pages: Math.ceil(alertLogs.count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching alert logs:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch alert logs',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/alerts/stats - Get alert statistics for current user
|
||||
router.get('/stats', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { hours = 24 } = req.query;
|
||||
const timeWindow = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
// Get user's alert rules
|
||||
const userRuleIds = await AlertRule.findAll({
|
||||
where: { user_id: req.user.id },
|
||||
attributes: ['id']
|
||||
}).then(rules => rules.map(rule => rule.id));
|
||||
|
||||
if (userRuleIds.length === 0) {
|
||||
return res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_alerts: 0,
|
||||
sent_alerts: 0,
|
||||
failed_alerts: 0,
|
||||
pending_alerts: 0,
|
||||
by_type: {},
|
||||
by_status: {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const [totalAlerts, alertsByStatus, alertsByType] = await Promise.all([
|
||||
AlertLog.count({
|
||||
where: {
|
||||
alert_rule_id: { [Op.in]: userRuleIds },
|
||||
created_at: { [Op.gte]: timeWindow }
|
||||
}
|
||||
}),
|
||||
AlertLog.findAll({
|
||||
where: {
|
||||
alert_rule_id: { [Op.in]: userRuleIds },
|
||||
created_at: { [Op.gte]: timeWindow }
|
||||
},
|
||||
attributes: [
|
||||
'status',
|
||||
[sequelize.fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['status'],
|
||||
raw: true
|
||||
}),
|
||||
AlertLog.findAll({
|
||||
where: {
|
||||
alert_rule_id: { [Op.in]: userRuleIds },
|
||||
created_at: { [Op.gte]: timeWindow }
|
||||
},
|
||||
attributes: [
|
||||
'alert_type',
|
||||
[sequelize.fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['alert_type'],
|
||||
raw: true
|
||||
})
|
||||
]);
|
||||
|
||||
const statusCounts = alertsByStatus.reduce((acc, item) => {
|
||||
acc[item.status] = parseInt(item.count);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const typeCounts = alertsByType.reduce((acc, item) => {
|
||||
acc[item.alert_type] = parseInt(item.count);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_alerts: totalAlerts,
|
||||
sent_alerts: statusCounts.sent || 0,
|
||||
failed_alerts: statusCounts.failed || 0,
|
||||
pending_alerts: statusCounts.pending || 0,
|
||||
by_type: typeCounts,
|
||||
by_status: statusCounts,
|
||||
time_window_hours: hours
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching alert statistics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch alert statistics',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
270
server/routes/dashboard.js
Normal file
270
server/routes/dashboard.js
Normal file
@@ -0,0 +1,270 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { DroneDetection, Device, Heartbeat } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
const { sequelize } = require('../models');
|
||||
|
||||
// GET /api/dashboard/overview - Get dashboard overview statistics
|
||||
router.get('/overview', async (req, res) => {
|
||||
try {
|
||||
const { hours = 24 } = req.query;
|
||||
const timeWindow = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
// Get basic statistics
|
||||
const [
|
||||
totalDevices,
|
||||
activeDevices,
|
||||
totalDetections,
|
||||
recentDetections,
|
||||
uniqueDronesDetected
|
||||
] = await Promise.all([
|
||||
Device.count(),
|
||||
Device.count({ where: { is_active: true } }),
|
||||
DroneDetection.count(),
|
||||
DroneDetection.count({
|
||||
where: { server_timestamp: { [Op.gte]: timeWindow } }
|
||||
}),
|
||||
DroneDetection.count({
|
||||
where: { server_timestamp: { [Op.gte]: timeWindow } },
|
||||
distinct: true,
|
||||
col: 'drone_id'
|
||||
})
|
||||
]);
|
||||
|
||||
// Get device status breakdown
|
||||
const devices = await Device.findAll({
|
||||
attributes: ['id', 'last_heartbeat', 'heartbeat_interval', 'is_active']
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
let onlineDevices = 0;
|
||||
let offlineDevices = 0;
|
||||
|
||||
devices.forEach(device => {
|
||||
if (!device.is_active) return;
|
||||
|
||||
const timeSinceLastHeartbeat = device.last_heartbeat
|
||||
? (now - new Date(device.last_heartbeat)) / 1000
|
||||
: null;
|
||||
|
||||
const expectedInterval = device.heartbeat_interval || 300;
|
||||
const isOnline = timeSinceLastHeartbeat && timeSinceLastHeartbeat < (expectedInterval * 2);
|
||||
|
||||
if (isOnline) {
|
||||
onlineDevices++;
|
||||
} else {
|
||||
offlineDevices++;
|
||||
}
|
||||
});
|
||||
|
||||
// Get recent alerts count
|
||||
// This would require AlertLog model which we haven't imported yet
|
||||
// const recentAlerts = await AlertLog.count({
|
||||
// where: { created_at: { [Op.gte]: timeWindow } }
|
||||
// });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary: {
|
||||
total_devices: totalDevices,
|
||||
active_devices: activeDevices,
|
||||
online_devices: onlineDevices,
|
||||
offline_devices: offlineDevices,
|
||||
total_detections: totalDetections,
|
||||
recent_detections: recentDetections,
|
||||
unique_drones_detected: uniqueDronesDetected,
|
||||
// recent_alerts: recentAlerts || 0,
|
||||
time_window_hours: hours
|
||||
},
|
||||
device_status: {
|
||||
total: totalDevices,
|
||||
active: activeDevices,
|
||||
online: onlineDevices,
|
||||
offline: offlineDevices,
|
||||
inactive: totalDevices - activeDevices
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard overview:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch dashboard overview',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/dashboard/activity - Get recent activity feed
|
||||
router.get('/activity', async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, hours = 24 } = req.query;
|
||||
const timeWindow = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
// Get recent detections with device info
|
||||
const recentDetections = await DroneDetection.findAll({
|
||||
where: { server_timestamp: { [Op.gte]: timeWindow } },
|
||||
include: [{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['id', 'name', 'geo_lat', 'geo_lon', 'location_description']
|
||||
}],
|
||||
limit: Math.min(parseInt(limit), 200),
|
||||
order: [['server_timestamp', 'DESC']]
|
||||
});
|
||||
|
||||
// Get recent heartbeats
|
||||
const recentHeartbeats = await Heartbeat.findAll({
|
||||
where: { received_at: { [Op.gte]: timeWindow } },
|
||||
include: [{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['id', 'name', 'geo_lat', 'geo_lon']
|
||||
}],
|
||||
limit: Math.min(parseInt(limit), 50),
|
||||
order: [['received_at', 'DESC']]
|
||||
});
|
||||
|
||||
// Combine and sort activities
|
||||
const activities = [
|
||||
...recentDetections.map(detection => ({
|
||||
type: 'detection',
|
||||
timestamp: detection.server_timestamp,
|
||||
data: {
|
||||
detection_id: detection.id,
|
||||
device_id: detection.device_id,
|
||||
device_name: detection.device.name || `Device ${detection.device_id}`,
|
||||
drone_id: detection.drone_id,
|
||||
drone_type: detection.drone_type,
|
||||
rssi: detection.rssi,
|
||||
freq: detection.freq,
|
||||
location: detection.device.location_description ||
|
||||
`${detection.device.geo_lat}, ${detection.device.geo_lon}`
|
||||
}
|
||||
})),
|
||||
...recentHeartbeats.map(heartbeat => ({
|
||||
type: 'heartbeat',
|
||||
timestamp: heartbeat.received_at,
|
||||
data: {
|
||||
device_id: heartbeat.device_id,
|
||||
device_name: heartbeat.device.name || `Device ${heartbeat.device_id}`,
|
||||
battery_level: heartbeat.battery_level,
|
||||
signal_strength: heartbeat.signal_strength,
|
||||
temperature: heartbeat.temperature
|
||||
}
|
||||
}))
|
||||
];
|
||||
|
||||
// Sort by timestamp descending
|
||||
activities.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: activities.slice(0, parseInt(limit))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching dashboard activity:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch dashboard activity',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/dashboard/charts/detections - Get detection chart data
|
||||
router.get('/charts/detections', async (req, res) => {
|
||||
try {
|
||||
const { hours = 24, interval = 'hour' } = req.query;
|
||||
const timeWindow = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
let groupBy;
|
||||
switch (interval) {
|
||||
case 'minute':
|
||||
groupBy = "DATE_TRUNC('minute', server_timestamp)";
|
||||
break;
|
||||
case 'hour':
|
||||
groupBy = "DATE_TRUNC('hour', server_timestamp)";
|
||||
break;
|
||||
case 'day':
|
||||
groupBy = "DATE_TRUNC('day', server_timestamp)";
|
||||
break;
|
||||
default:
|
||||
groupBy = "DATE_TRUNC('hour', server_timestamp)";
|
||||
}
|
||||
|
||||
const detectionCounts = await DroneDetection.findAll({
|
||||
where: { server_timestamp: { [Op.gte]: timeWindow } },
|
||||
attributes: [
|
||||
[sequelize.fn('DATE_TRUNC', interval, sequelize.col('server_timestamp')), 'time_bucket'],
|
||||
[sequelize.fn('COUNT', '*'), 'count']
|
||||
],
|
||||
group: ['time_bucket'],
|
||||
order: [['time_bucket', 'ASC']],
|
||||
raw: true
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: detectionCounts.map(item => ({
|
||||
timestamp: item.time_bucket,
|
||||
count: parseInt(item.count)
|
||||
}))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching detection chart data:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch detection chart data',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/dashboard/charts/devices - Get device activity chart data
|
||||
router.get('/charts/devices', async (req, res) => {
|
||||
try {
|
||||
const { hours = 24 } = req.query;
|
||||
const timeWindow = new Date(Date.now() - hours * 60 * 60 * 1000);
|
||||
|
||||
const deviceActivity = await DroneDetection.findAll({
|
||||
where: { server_timestamp: { [Op.gte]: timeWindow } },
|
||||
attributes: [
|
||||
'device_id',
|
||||
[sequelize.fn('COUNT', '*'), 'detection_count']
|
||||
],
|
||||
include: [{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['name', 'location_description']
|
||||
}],
|
||||
group: ['device_id', 'device.id', 'device.name', 'device.location_description'],
|
||||
order: [[sequelize.fn('COUNT', '*'), 'DESC']],
|
||||
raw: false
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: deviceActivity.map(item => ({
|
||||
device_id: item.device_id,
|
||||
device_name: item.device.name || `Device ${item.device_id}`,
|
||||
location: item.device.location_description,
|
||||
detection_count: parseInt(item.dataValues.detection_count)
|
||||
}))
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching device chart data:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch device chart data',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
318
server/routes/device.js
Normal file
318
server/routes/device.js
Normal file
@@ -0,0 +1,318 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Joi = require('joi');
|
||||
const { Device, DroneDetection, Heartbeat } = require('../models');
|
||||
const { validateRequest } = require('../middleware/validation');
|
||||
const { authenticateToken } = require('../middleware/auth');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
// Validation schema for device
|
||||
const deviceSchema = Joi.object({
|
||||
id: Joi.number().integer().required(),
|
||||
name: Joi.string().max(255).optional(),
|
||||
geo_lat: Joi.number().min(-90).max(90).optional(),
|
||||
geo_lon: Joi.number().min(-180).max(180).optional(),
|
||||
location_description: Joi.string().optional(),
|
||||
heartbeat_interval: Joi.number().integer().min(60).max(3600).optional(),
|
||||
firmware_version: Joi.string().optional(),
|
||||
installation_date: Joi.date().optional(),
|
||||
notes: Joi.string().optional()
|
||||
});
|
||||
|
||||
const updateDeviceSchema = Joi.object({
|
||||
name: Joi.string().max(255).optional(),
|
||||
geo_lat: Joi.number().min(-90).max(90).optional(),
|
||||
geo_lon: Joi.number().min(-180).max(180).optional(),
|
||||
location_description: Joi.string().optional(),
|
||||
is_active: Joi.boolean().optional(),
|
||||
heartbeat_interval: Joi.number().integer().min(60).max(3600).optional(),
|
||||
firmware_version: Joi.string().optional(),
|
||||
installation_date: Joi.date().optional(),
|
||||
notes: Joi.string().optional()
|
||||
});
|
||||
|
||||
// GET /api/devices - Get all devices
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
include_stats = false,
|
||||
active_only = false,
|
||||
limit = 100,
|
||||
offset = 0
|
||||
} = req.query;
|
||||
|
||||
const whereClause = {};
|
||||
if (active_only === 'true') {
|
||||
whereClause.is_active = true;
|
||||
}
|
||||
|
||||
const includeOptions = [];
|
||||
|
||||
if (include_stats === 'true') {
|
||||
// Include latest heartbeat and detection count
|
||||
includeOptions.push({
|
||||
model: Heartbeat,
|
||||
as: 'heartbeats',
|
||||
limit: 1,
|
||||
order: [['received_at', 'DESC']],
|
||||
required: false,
|
||||
attributes: ['received_at', 'battery_level', 'signal_strength', 'temperature']
|
||||
});
|
||||
}
|
||||
|
||||
const devices = await Device.findAndCountAll({
|
||||
where: whereClause,
|
||||
include: includeOptions,
|
||||
limit: Math.min(parseInt(limit), 1000),
|
||||
offset: parseInt(offset),
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
// If stats requested, get detection counts
|
||||
let devicesWithStats = devices.rows;
|
||||
if (include_stats === 'true') {
|
||||
devicesWithStats = await Promise.all(devices.rows.map(async (device) => {
|
||||
const detectionCount = await DroneDetection.count({
|
||||
where: {
|
||||
device_id: device.id,
|
||||
server_timestamp: {
|
||||
[Op.gte]: new Date(Date.now() - 24 * 60 * 60 * 1000) // Last 24 hours
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const timeSinceLastHeartbeat = device.last_heartbeat
|
||||
? (now - new Date(device.last_heartbeat)) / 1000
|
||||
: null;
|
||||
|
||||
const expectedInterval = device.heartbeat_interval || 300;
|
||||
const isOnline = timeSinceLastHeartbeat && timeSinceLastHeartbeat < (expectedInterval * 2);
|
||||
|
||||
return {
|
||||
...device.toJSON(),
|
||||
stats: {
|
||||
detections_24h: detectionCount,
|
||||
status: device.is_active ? (isOnline ? 'online' : 'offline') : 'inactive',
|
||||
time_since_last_heartbeat: timeSinceLastHeartbeat
|
||||
}
|
||||
};
|
||||
}));
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: devicesWithStats,
|
||||
pagination: {
|
||||
total: devices.count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
pages: Math.ceil(devices.count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching devices:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch devices',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/map - Get devices with location data for map display
|
||||
router.get('/map', async (req, res) => {
|
||||
try {
|
||||
const devices = await Device.findAll({
|
||||
where: {
|
||||
is_active: true,
|
||||
geo_lat: { [Op.ne]: null },
|
||||
geo_lon: { [Op.ne]: null }
|
||||
},
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'geo_lat',
|
||||
'geo_lon',
|
||||
'location_description',
|
||||
'last_heartbeat'
|
||||
]
|
||||
});
|
||||
|
||||
// Get recent detections for each device
|
||||
const devicesWithDetections = await Promise.all(devices.map(async (device) => {
|
||||
const recentDetections = await DroneDetection.count({
|
||||
where: {
|
||||
device_id: device.id,
|
||||
server_timestamp: {
|
||||
[Op.gte]: new Date(Date.now() - 10 * 60 * 1000) // Last 10 minutes
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const timeSinceLastHeartbeat = device.last_heartbeat
|
||||
? (now - new Date(device.last_heartbeat)) / 1000
|
||||
: null;
|
||||
|
||||
const isOnline = timeSinceLastHeartbeat && timeSinceLastHeartbeat < 600; // 10 minutes
|
||||
|
||||
return {
|
||||
...device.toJSON(),
|
||||
has_recent_detections: recentDetections > 0,
|
||||
detection_count_10m: recentDetections,
|
||||
status: isOnline ? 'online' : 'offline'
|
||||
};
|
||||
}));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: devicesWithDetections
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching devices for map:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch devices for map',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/devices/:id - Get specific device
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const device = await Device.findByPk(req.params.id, {
|
||||
include: [
|
||||
{
|
||||
model: Heartbeat,
|
||||
as: 'heartbeats',
|
||||
limit: 5,
|
||||
order: [['received_at', 'DESC']]
|
||||
},
|
||||
{
|
||||
model: DroneDetection,
|
||||
as: 'detections',
|
||||
limit: 10,
|
||||
order: [['server_timestamp', 'DESC']]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
if (!device) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Device not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: device
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching device:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch device',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/devices - Create new device (admin only)
|
||||
router.post('/', authenticateToken, validateRequest(deviceSchema), async (req, res) => {
|
||||
try {
|
||||
const device = await Device.create(req.body);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: device,
|
||||
message: 'Device created successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating device:', error);
|
||||
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'Device with this ID already exists'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to create device',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/devices/:id - Update device (admin only)
|
||||
router.put('/:id', authenticateToken, validateRequest(updateDeviceSchema), async (req, res) => {
|
||||
try {
|
||||
const device = await Device.findByPk(req.params.id);
|
||||
|
||||
if (!device) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Device not found'
|
||||
});
|
||||
}
|
||||
|
||||
await device.update(req.body);
|
||||
|
||||
// Emit real-time update
|
||||
req.io.emit('device_updated', device);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: device,
|
||||
message: 'Device updated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating device:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update device',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/devices/:id - Delete device (admin only)
|
||||
router.delete('/:id', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const device = await Device.findByPk(req.params.id);
|
||||
|
||||
if (!device) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Device not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Soft delete by setting is_active to false
|
||||
await device.update({ is_active: false });
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Device deactivated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error deleting device:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to delete device',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
228
server/routes/droneDetection.js
Normal file
228
server/routes/droneDetection.js
Normal file
@@ -0,0 +1,228 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Joi = require('joi');
|
||||
const { DroneDetection, Device } = require('../models');
|
||||
const { processAlert } = require('../services/alertService');
|
||||
const { validateRequest } = require('../middleware/validation');
|
||||
|
||||
// Validation schema for drone detection
|
||||
const droneDetectionSchema = Joi.object({
|
||||
device_id: Joi.number().integer().required(),
|
||||
geo_lat: Joi.number().min(-90).max(90).default(0),
|
||||
geo_lon: Joi.number().min(-180).max(180).default(0),
|
||||
device_timestamp: Joi.number().integer().min(0).default(0),
|
||||
drone_type: Joi.number().integer().min(0).default(0),
|
||||
rssi: Joi.number().integer().default(0),
|
||||
freq: Joi.number().integer().required(),
|
||||
drone_id: Joi.number().integer().required(),
|
||||
confidence_level: Joi.number().min(0).max(1).optional(),
|
||||
signal_duration: Joi.number().integer().min(0).optional()
|
||||
});
|
||||
|
||||
// POST /api/detections - Receive drone detection data
|
||||
router.post('/', validateRequest(droneDetectionSchema), async (req, res) => {
|
||||
try {
|
||||
const detectionData = req.body;
|
||||
|
||||
// Ensure device exists or create it
|
||||
const [device] = await Device.findOrCreate({
|
||||
where: { id: detectionData.device_id },
|
||||
defaults: {
|
||||
id: detectionData.device_id,
|
||||
geo_lat: detectionData.geo_lat || 0,
|
||||
geo_lon: detectionData.geo_lon || 0,
|
||||
last_heartbeat: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Create the detection record
|
||||
const detection = await DroneDetection.create({
|
||||
...detectionData,
|
||||
server_timestamp: new Date()
|
||||
});
|
||||
|
||||
// Emit real-time update via Socket.IO
|
||||
req.io.emit('drone_detection', {
|
||||
id: detection.id,
|
||||
device_id: detection.device_id,
|
||||
drone_id: detection.drone_id,
|
||||
drone_type: detection.drone_type,
|
||||
rssi: detection.rssi,
|
||||
freq: detection.freq,
|
||||
geo_lat: detection.geo_lat,
|
||||
geo_lon: detection.geo_lon,
|
||||
server_timestamp: detection.server_timestamp,
|
||||
device: {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
geo_lat: device.geo_lat,
|
||||
geo_lon: device.geo_lon
|
||||
}
|
||||
});
|
||||
|
||||
// Process alerts asynchronously
|
||||
processAlert(detection).catch(error => {
|
||||
console.error('Alert processing error:', error);
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: detection,
|
||||
message: 'Drone detection recorded successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating drone detection:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to record drone detection',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/detections - Get drone detections with filtering
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
device_id,
|
||||
drone_id,
|
||||
start_date,
|
||||
end_date,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
order = 'DESC'
|
||||
} = req.query;
|
||||
|
||||
const whereClause = {};
|
||||
|
||||
if (device_id) whereClause.device_id = device_id;
|
||||
if (drone_id) whereClause.drone_id = drone_id;
|
||||
|
||||
if (start_date || end_date) {
|
||||
whereClause.server_timestamp = {};
|
||||
if (start_date) whereClause.server_timestamp[Op.gte] = new Date(start_date);
|
||||
if (end_date) whereClause.server_timestamp[Op.lte] = new Date(end_date);
|
||||
}
|
||||
|
||||
const detections = await DroneDetection.findAndCountAll({
|
||||
where: whereClause,
|
||||
include: [{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['id', 'name', 'geo_lat', 'geo_lon', 'location_description']
|
||||
}],
|
||||
limit: Math.min(parseInt(limit), 1000), // Max 1000 records
|
||||
offset: parseInt(offset),
|
||||
order: [['server_timestamp', order]]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: detections.rows,
|
||||
pagination: {
|
||||
total: detections.count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
pages: Math.ceil(detections.count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching drone detections:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch drone detections',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/detections/stats - Get detection statistics
|
||||
router.get('/stats', async (req, res) => {
|
||||
try {
|
||||
const { device_id, hours = 24 } = req.query;
|
||||
|
||||
const whereClause = {
|
||||
server_timestamp: {
|
||||
[Op.gte]: new Date(Date.now() - hours * 60 * 60 * 1000)
|
||||
}
|
||||
};
|
||||
|
||||
if (device_id) whereClause.device_id = device_id;
|
||||
|
||||
const [totalDetections, uniqueDrones, uniqueDevices, avgRssi] = await Promise.all([
|
||||
DroneDetection.count({ where: whereClause }),
|
||||
DroneDetection.count({
|
||||
where: whereClause,
|
||||
distinct: true,
|
||||
col: 'drone_id'
|
||||
}),
|
||||
DroneDetection.count({
|
||||
where: whereClause,
|
||||
distinct: true,
|
||||
col: 'device_id'
|
||||
}),
|
||||
DroneDetection.findAll({
|
||||
where: whereClause,
|
||||
attributes: [
|
||||
[sequelize.fn('AVG', sequelize.col('rssi')), 'avg_rssi']
|
||||
]
|
||||
})
|
||||
]);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
total_detections: totalDetections,
|
||||
unique_drones: uniqueDrones,
|
||||
active_devices: uniqueDevices,
|
||||
average_rssi: Math.round(avgRssi[0]?.dataValues?.avg_rssi || 0),
|
||||
time_period_hours: hours
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching detection stats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch detection statistics',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/detections/:id - Get specific detection
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const detection = await DroneDetection.findByPk(req.params.id, {
|
||||
include: [{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['id', 'name', 'geo_lat', 'geo_lon', 'location_description']
|
||||
}]
|
||||
});
|
||||
|
||||
if (!detection) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
message: 'Detection not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: detection
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching detection:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch detection',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
54
server/routes/health.js
Normal file
54
server/routes/health.js
Normal file
@@ -0,0 +1,54 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Health check endpoint
|
||||
router.get('/', (req, res) => {
|
||||
const healthcheck = {
|
||||
uptime: process.uptime(),
|
||||
message: 'OK',
|
||||
timestamp: Date.now(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
version: process.env.npm_package_version || '1.0.0'
|
||||
};
|
||||
|
||||
try {
|
||||
res.status(200).json(healthcheck);
|
||||
} catch (error) {
|
||||
healthcheck.message = error;
|
||||
res.status(503).json(healthcheck);
|
||||
}
|
||||
});
|
||||
|
||||
// Detailed health check with database connection
|
||||
router.get('/detailed', async (req, res) => {
|
||||
const healthcheck = {
|
||||
uptime: process.uptime(),
|
||||
message: 'OK',
|
||||
timestamp: Date.now(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
services: {}
|
||||
};
|
||||
|
||||
try {
|
||||
// Check database connection
|
||||
const { sequelize } = require('../models');
|
||||
await sequelize.authenticate();
|
||||
healthcheck.services.database = 'connected';
|
||||
|
||||
// Check Redis connection (if configured)
|
||||
if (process.env.REDIS_HOST) {
|
||||
// Add Redis check if implemented
|
||||
healthcheck.services.redis = 'not_implemented';
|
||||
}
|
||||
|
||||
res.status(200).json(healthcheck);
|
||||
} catch (error) {
|
||||
healthcheck.message = 'Service Unavailable';
|
||||
healthcheck.services.database = 'disconnected';
|
||||
healthcheck.error = error.message;
|
||||
res.status(503).json(healthcheck);
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
199
server/routes/heartbeat.js
Normal file
199
server/routes/heartbeat.js
Normal file
@@ -0,0 +1,199 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Joi = require('joi');
|
||||
const { Heartbeat, Device } = require('../models');
|
||||
const { validateRequest } = require('../middleware/validation');
|
||||
|
||||
// Validation schema for heartbeat
|
||||
const heartbeatSchema = Joi.object({
|
||||
type: Joi.string().valid('heartbeat').required(),
|
||||
key: Joi.string().required(),
|
||||
device_id: Joi.number().integer().optional(),
|
||||
signal_strength: Joi.number().integer().optional(),
|
||||
battery_level: Joi.number().integer().min(0).max(100).optional(),
|
||||
temperature: Joi.number().optional(),
|
||||
uptime: Joi.number().integer().min(0).optional(),
|
||||
memory_usage: Joi.number().integer().min(0).max(100).optional(),
|
||||
firmware_version: Joi.string().optional()
|
||||
});
|
||||
|
||||
// POST /api/heartbeat - Receive heartbeat from devices
|
||||
router.post('/', validateRequest(heartbeatSchema), async (req, res) => {
|
||||
try {
|
||||
const { type, key, device_id, ...heartbeatData } = req.body;
|
||||
|
||||
// If device_id is not provided, try to find device by key
|
||||
let deviceId = device_id;
|
||||
if (!deviceId) {
|
||||
// Try to extract device ID from key or use key as identifier
|
||||
// This is a fallback for devices that only send key
|
||||
const keyMatch = key.match(/device[_-]?(\d+)/i);
|
||||
deviceId = keyMatch ? parseInt(keyMatch[1]) : key.hashCode(); // Simple hash if no pattern
|
||||
}
|
||||
|
||||
// Ensure device exists or create it
|
||||
const [device] = await Device.findOrCreate({
|
||||
where: { id: deviceId },
|
||||
defaults: {
|
||||
id: deviceId,
|
||||
name: `Device ${deviceId}`,
|
||||
last_heartbeat: new Date()
|
||||
}
|
||||
});
|
||||
|
||||
// Update device's last heartbeat
|
||||
await device.update({ last_heartbeat: new Date() });
|
||||
|
||||
// Create heartbeat record
|
||||
const heartbeat = await Heartbeat.create({
|
||||
device_id: deviceId,
|
||||
device_key: key,
|
||||
...heartbeatData,
|
||||
received_at: new Date()
|
||||
});
|
||||
|
||||
// Emit real-time update via Socket.IO
|
||||
req.io.emit('device_heartbeat', {
|
||||
device_id: deviceId,
|
||||
device_key: key,
|
||||
timestamp: heartbeat.received_at,
|
||||
status: 'online',
|
||||
...heartbeatData
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: heartbeat,
|
||||
message: 'Heartbeat recorded successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing heartbeat:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to process heartbeat',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/heartbeat/status - Get device status overview
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const devices = await Device.findAll({
|
||||
attributes: [
|
||||
'id',
|
||||
'name',
|
||||
'geo_lat',
|
||||
'geo_lon',
|
||||
'last_heartbeat',
|
||||
'heartbeat_interval',
|
||||
'is_active'
|
||||
],
|
||||
include: [{
|
||||
model: Heartbeat,
|
||||
as: 'heartbeats',
|
||||
limit: 1,
|
||||
order: [['received_at', 'DESC']],
|
||||
attributes: ['battery_level', 'signal_strength', 'temperature', 'firmware_version']
|
||||
}]
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const deviceStatus = devices.map(device => {
|
||||
const timeSinceLastHeartbeat = device.last_heartbeat
|
||||
? (now - new Date(device.last_heartbeat)) / 1000
|
||||
: null;
|
||||
|
||||
const expectedInterval = device.heartbeat_interval || 300; // 5 minutes default
|
||||
const isOnline = timeSinceLastHeartbeat && timeSinceLastHeartbeat < (expectedInterval * 2);
|
||||
|
||||
return {
|
||||
device_id: device.id,
|
||||
name: device.name,
|
||||
geo_lat: device.geo_lat,
|
||||
geo_lon: device.geo_lon,
|
||||
status: device.is_active ? (isOnline ? 'online' : 'offline') : 'inactive',
|
||||
last_heartbeat: device.last_heartbeat,
|
||||
time_since_last_heartbeat: timeSinceLastHeartbeat,
|
||||
latest_data: device.heartbeats[0] || null
|
||||
};
|
||||
});
|
||||
|
||||
const summary = {
|
||||
total_devices: devices.length,
|
||||
online: deviceStatus.filter(d => d.status === 'online').length,
|
||||
offline: deviceStatus.filter(d => d.status === 'offline').length,
|
||||
inactive: deviceStatus.filter(d => d.status === 'inactive').length
|
||||
};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
summary,
|
||||
devices: deviceStatus
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching device status:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch device status',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/heartbeat/device/:deviceId - Get heartbeat history for specific device
|
||||
router.get('/device/:deviceId', async (req, res) => {
|
||||
try {
|
||||
const { deviceId } = req.params;
|
||||
const { limit = 50, offset = 0 } = req.query;
|
||||
|
||||
const heartbeats = await Heartbeat.findAndCountAll({
|
||||
where: { device_id: deviceId },
|
||||
limit: Math.min(parseInt(limit), 1000),
|
||||
offset: parseInt(offset),
|
||||
order: [['received_at', 'DESC']],
|
||||
include: [{
|
||||
model: Device,
|
||||
as: 'device',
|
||||
attributes: ['id', 'name', 'geo_lat', 'geo_lon']
|
||||
}]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: heartbeats.rows,
|
||||
pagination: {
|
||||
total: heartbeats.count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
pages: Math.ceil(heartbeats.count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching device heartbeats:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch device heartbeats',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Helper function to generate simple hash from string
|
||||
String.prototype.hashCode = function() {
|
||||
let hash = 0;
|
||||
if (this.length === 0) return hash;
|
||||
for (let i = 0; i < this.length; i++) {
|
||||
const char = this.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return Math.abs(hash);
|
||||
};
|
||||
|
||||
module.exports = router;
|
||||
45
server/routes/index.js
Normal file
45
server/routes/index.js
Normal file
@@ -0,0 +1,45 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Import route modules
|
||||
const droneDetectionRoutes = require('./droneDetection');
|
||||
const heartbeatRoutes = require('./heartbeat');
|
||||
const deviceRoutes = require('./device');
|
||||
const userRoutes = require('./user');
|
||||
const alertRoutes = require('./alert');
|
||||
const dashboardRoutes = require('./dashboard');
|
||||
|
||||
// API versioning
|
||||
router.use('/v1/detections', droneDetectionRoutes);
|
||||
router.use('/v1/heartbeat', heartbeatRoutes);
|
||||
router.use('/v1/devices', deviceRoutes);
|
||||
router.use('/v1/users', userRoutes);
|
||||
router.use('/v1/alerts', alertRoutes);
|
||||
router.use('/v1/dashboard', dashboardRoutes);
|
||||
|
||||
// Default routes (no version prefix for backward compatibility)
|
||||
router.use('/detections', droneDetectionRoutes);
|
||||
router.use('/heartbeat', heartbeatRoutes);
|
||||
router.use('/devices', deviceRoutes);
|
||||
router.use('/users', userRoutes);
|
||||
router.use('/alerts', alertRoutes);
|
||||
router.use('/dashboard', dashboardRoutes);
|
||||
|
||||
// API documentation endpoint
|
||||
router.get('/', (req, res) => {
|
||||
res.json({
|
||||
message: 'Drone Detection System API',
|
||||
version: '1.0.0',
|
||||
endpoints: {
|
||||
detections: '/api/detections',
|
||||
heartbeat: '/api/heartbeat',
|
||||
devices: '/api/devices',
|
||||
users: '/api/users',
|
||||
alerts: '/api/alerts',
|
||||
dashboard: '/api/dashboard'
|
||||
},
|
||||
documentation: '/api/docs'
|
||||
});
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
214
server/routes/user.js
Normal file
214
server/routes/user.js
Normal file
@@ -0,0 +1,214 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const Joi = require('joi');
|
||||
const bcrypt = require('bcryptjs');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { User } = require('../models');
|
||||
const { validateRequest } = require('../middleware/validation');
|
||||
const { authenticateToken, requireRole } = require('../middleware/auth');
|
||||
|
||||
// Validation schemas
|
||||
const registerSchema = Joi.object({
|
||||
username: Joi.string().min(3).max(50).required(),
|
||||
email: Joi.string().email().required(),
|
||||
password: Joi.string().min(6).required(),
|
||||
first_name: Joi.string().optional(),
|
||||
last_name: Joi.string().optional(),
|
||||
phone_number: Joi.string().optional(),
|
||||
role: Joi.string().valid('admin', 'operator', 'viewer').default('viewer')
|
||||
});
|
||||
|
||||
const loginSchema = Joi.object({
|
||||
username: Joi.string().required(),
|
||||
password: Joi.string().required()
|
||||
});
|
||||
|
||||
const updateProfileSchema = Joi.object({
|
||||
first_name: Joi.string().optional(),
|
||||
last_name: Joi.string().optional(),
|
||||
phone_number: Joi.string().optional(),
|
||||
sms_alerts_enabled: Joi.boolean().optional(),
|
||||
email_alerts_enabled: Joi.boolean().optional(),
|
||||
timezone: Joi.string().optional()
|
||||
});
|
||||
|
||||
// POST /api/users/register - Register new user
|
||||
router.post('/register', validateRequest(registerSchema), async (req, res) => {
|
||||
try {
|
||||
const { password, ...userData } = req.body;
|
||||
|
||||
// Hash password
|
||||
const saltRounds = 12;
|
||||
const password_hash = await bcrypt.hash(password, saltRounds);
|
||||
|
||||
// Create user
|
||||
const user = await User.create({
|
||||
...userData,
|
||||
password_hash
|
||||
});
|
||||
|
||||
// Remove password hash from response
|
||||
const { password_hash: _, ...userResponse } = user.toJSON();
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: userResponse,
|
||||
message: 'User registered successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error registering user:', error);
|
||||
|
||||
if (error.name === 'SequelizeUniqueConstraintError') {
|
||||
return res.status(409).json({
|
||||
success: false,
|
||||
message: 'Username or email already exists'
|
||||
});
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to register user',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/users/login - User login
|
||||
router.post('/login', validateRequest(loginSchema), async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
// Find user by username or email
|
||||
const user = await User.findOne({
|
||||
where: {
|
||||
[Op.or]: [
|
||||
{ username: username },
|
||||
{ email: username }
|
||||
],
|
||||
is_active: true
|
||||
}
|
||||
});
|
||||
|
||||
if (!user || !await bcrypt.compare(password, user.password_hash)) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
message: 'Invalid credentials'
|
||||
});
|
||||
}
|
||||
|
||||
// Update last login
|
||||
await user.update({ last_login: new Date() });
|
||||
|
||||
// Generate JWT token
|
||||
const token = jwt.sign(
|
||||
{ userId: user.id, username: user.username, role: user.role },
|
||||
process.env.JWT_SECRET,
|
||||
{ expiresIn: '24h' }
|
||||
);
|
||||
|
||||
// Remove password hash from response
|
||||
const { password_hash: _, ...userResponse } = user.toJSON();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
user: userResponse,
|
||||
token,
|
||||
expires_in: '24h'
|
||||
},
|
||||
message: 'Login successful'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during login:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Login failed',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/users/profile - Get current user profile
|
||||
router.get('/profile', authenticateToken, async (req, res) => {
|
||||
try {
|
||||
const { password_hash: _, ...userProfile } = req.user.toJSON();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: userProfile
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching user profile:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch user profile',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/users/profile - Update user profile
|
||||
router.put('/profile', authenticateToken, validateRequest(updateProfileSchema), async (req, res) => {
|
||||
try {
|
||||
await req.user.update(req.body);
|
||||
|
||||
const { password_hash: _, ...userResponse } = req.user.toJSON();
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: userResponse,
|
||||
message: 'Profile updated successfully'
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating user profile:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to update profile',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/users - Get all users (admin only)
|
||||
router.get('/', authenticateToken, requireRole(['admin']), async (req, res) => {
|
||||
try {
|
||||
const { limit = 50, offset = 0, role, is_active } = req.query;
|
||||
|
||||
const whereClause = {};
|
||||
if (role) whereClause.role = role;
|
||||
if (is_active !== undefined) whereClause.is_active = is_active === 'true';
|
||||
|
||||
const users = await User.findAndCountAll({
|
||||
where: whereClause,
|
||||
attributes: { exclude: ['password_hash'] },
|
||||
limit: Math.min(parseInt(limit), 100),
|
||||
offset: parseInt(offset),
|
||||
order: [['created_at', 'DESC']]
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: users.rows,
|
||||
pagination: {
|
||||
total: users.count,
|
||||
limit: parseInt(limit),
|
||||
offset: parseInt(offset),
|
||||
pages: Math.ceil(users.count / parseInt(limit))
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error fetching users:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to fetch users',
|
||||
error: process.env.NODE_ENV === 'development' ? error.message : 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
17
server/scripts/init-db.sql
Normal file
17
server/scripts/init-db.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- Database initialization script for Docker
|
||||
-- This script sets up the initial database structure
|
||||
|
||||
-- Enable extensions
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
|
||||
-- Set timezone
|
||||
SET timezone = 'Europe/Stockholm';
|
||||
|
||||
-- Create indexes for better performance
|
||||
-- (These will be created by Sequelize, but we can optimize here)
|
||||
|
||||
-- Log successful initialization
|
||||
INSERT INTO pg_stat_statements_info (dealloc) VALUES (0) ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Grant necessary permissions
|
||||
GRANT ALL PRIVILEGES ON DATABASE drone_detection TO postgres;
|
||||
331
server/scripts/setup-database.js
Normal file
331
server/scripts/setup-database.js
Normal file
@@ -0,0 +1,331 @@
|
||||
const { Sequelize } = require('sequelize');
|
||||
const bcrypt = require('bcrypt');
|
||||
|
||||
// Import models
|
||||
const Device = require('../models/Device');
|
||||
const DroneDetection = require('../models/DroneDetection');
|
||||
const Heartbeat = require('../models/Heartbeat');
|
||||
const User = require('../models/User');
|
||||
const AlertRule = require('../models/AlertRule');
|
||||
const AlertLog = require('../models/AlertLog');
|
||||
|
||||
const setupDatabase = async () => {
|
||||
try {
|
||||
console.log('🚀 Starting database setup...\n');
|
||||
|
||||
// Load environment variables
|
||||
require('dotenv').config();
|
||||
|
||||
// Create Sequelize instance
|
||||
const sequelize = new Sequelize(
|
||||
process.env.DB_NAME,
|
||||
process.env.DB_USER,
|
||||
process.env.DB_PASSWORD,
|
||||
{
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT,
|
||||
dialect: 'postgres',
|
||||
logging: console.log,
|
||||
}
|
||||
);
|
||||
|
||||
// Test database connection
|
||||
console.log('📡 Testing database connection...');
|
||||
await sequelize.authenticate();
|
||||
console.log('✅ Database connection established successfully.\n');
|
||||
|
||||
// Initialize models
|
||||
console.log('📋 Initializing models...');
|
||||
Device.init(Device.getAttributes(), { sequelize, modelName: 'Device' });
|
||||
DroneDetection.init(DroneDetection.getAttributes(), { sequelize, modelName: 'DroneDetection' });
|
||||
Heartbeat.init(Heartbeat.getAttributes(), { sequelize, modelName: 'Heartbeat' });
|
||||
User.init(User.getAttributes(), { sequelize, modelName: 'User' });
|
||||
AlertRule.init(AlertRule.getAttributes(), { sequelize, modelName: 'AlertRule' });
|
||||
AlertLog.init(AlertLog.getAttributes(), { sequelize, modelName: 'AlertLog' });
|
||||
|
||||
// Define associations
|
||||
console.log('🔗 Setting up model associations...');
|
||||
|
||||
// Device associations
|
||||
Device.hasMany(DroneDetection, { foreignKey: 'device_id', as: 'detections' });
|
||||
Device.hasMany(Heartbeat, { foreignKey: 'device_id', as: 'heartbeats' });
|
||||
|
||||
// Detection associations
|
||||
DroneDetection.belongsTo(Device, { foreignKey: 'device_id', as: 'device' });
|
||||
|
||||
// Heartbeat associations
|
||||
Heartbeat.belongsTo(Device, { foreignKey: 'device_id', as: 'device' });
|
||||
|
||||
// User associations
|
||||
User.hasMany(AlertRule, { foreignKey: 'user_id', as: 'alertRules' });
|
||||
User.hasMany(AlertLog, { foreignKey: 'user_id', as: 'alertLogs' });
|
||||
|
||||
// Alert associations
|
||||
AlertRule.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
AlertRule.hasMany(AlertLog, { foreignKey: 'alert_rule_id', as: 'logs' });
|
||||
|
||||
AlertLog.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
|
||||
AlertLog.belongsTo(AlertRule, { foreignKey: 'alert_rule_id', as: 'alertRule' });
|
||||
AlertLog.belongsTo(DroneDetection, { foreignKey: 'detection_id', as: 'detection' });
|
||||
|
||||
// Sync database (create tables)
|
||||
console.log('🏗️ Creating database tables...');
|
||||
await sequelize.sync({ force: true }); // WARNING: This will drop existing tables
|
||||
console.log('✅ Database tables created successfully.\n');
|
||||
|
||||
// Create sample data
|
||||
console.log('📊 Creating sample data...\n');
|
||||
|
||||
// Create admin user
|
||||
console.log('👤 Creating admin user...');
|
||||
const adminUser = await User.create({
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
password: await bcrypt.hash('admin123', 10),
|
||||
role: 'admin'
|
||||
});
|
||||
console.log(`✅ Admin user created: ${adminUser.username}`);
|
||||
|
||||
// Create operator user
|
||||
console.log('👤 Creating operator user...');
|
||||
const operatorUser = await User.create({
|
||||
username: 'operator',
|
||||
email: 'operator@example.com',
|
||||
password: await bcrypt.hash('operator123', 10),
|
||||
role: 'operator'
|
||||
});
|
||||
console.log(`✅ Operator user created: ${operatorUser.username}`);
|
||||
|
||||
// Create sample devices
|
||||
console.log('📡 Creating sample devices...');
|
||||
const devices = await Device.bulkCreate([
|
||||
{
|
||||
device_id: 1941875381,
|
||||
name: 'Drone Detector Alpha',
|
||||
location: 'Stockholm Central',
|
||||
geo_lat: 59.3293,
|
||||
geo_lon: 18.0686,
|
||||
status: 'online',
|
||||
last_seen: new Date()
|
||||
},
|
||||
{
|
||||
device_id: 1941875382,
|
||||
name: 'Drone Detector Beta',
|
||||
location: 'Gothenburg Port',
|
||||
geo_lat: 57.7089,
|
||||
geo_lon: 11.9746,
|
||||
status: 'online',
|
||||
last_seen: new Date()
|
||||
},
|
||||
{
|
||||
device_id: 1941875383,
|
||||
name: 'Drone Detector Gamma',
|
||||
location: 'Malmö Airport',
|
||||
geo_lat: 55.6050,
|
||||
geo_lon: 13.0038,
|
||||
status: 'offline',
|
||||
last_seen: new Date(Date.now() - 2 * 60 * 60 * 1000) // 2 hours ago
|
||||
}
|
||||
]);
|
||||
console.log(`✅ Created ${devices.length} sample devices`);
|
||||
|
||||
// Create sample heartbeats
|
||||
console.log('💓 Creating sample heartbeats...');
|
||||
const heartbeats = await Heartbeat.bulkCreate([
|
||||
{
|
||||
device_id: 1941875381,
|
||||
battery_level: 85,
|
||||
signal_strength: -45,
|
||||
temperature: 22.5,
|
||||
status: 'active',
|
||||
timestamp: new Date()
|
||||
},
|
||||
{
|
||||
device_id: 1941875382,
|
||||
battery_level: 72,
|
||||
signal_strength: -38,
|
||||
temperature: 24.1,
|
||||
status: 'active',
|
||||
timestamp: new Date()
|
||||
}
|
||||
]);
|
||||
console.log(`✅ Created ${heartbeats.length} sample heartbeats`);
|
||||
|
||||
// Create sample drone detections
|
||||
console.log('🚁 Creating sample drone detections...');
|
||||
const detections = await DroneDetection.bulkCreate([
|
||||
{
|
||||
device_id: 1941875381,
|
||||
geo_lat: 59.3293,
|
||||
geo_lon: 18.0686,
|
||||
device_timestamp: Math.floor(Date.now() / 1000),
|
||||
drone_type: 0,
|
||||
rssi: -45,
|
||||
freq: 20,
|
||||
drone_id: 1001,
|
||||
timestamp: new Date(),
|
||||
threat_level: 'high',
|
||||
estimated_distance: 150,
|
||||
requires_action: true
|
||||
},
|
||||
{
|
||||
device_id: 1941875382,
|
||||
geo_lat: 57.7089,
|
||||
geo_lon: 11.9746,
|
||||
device_timestamp: Math.floor(Date.now() / 1000) - 3600,
|
||||
drone_type: 1,
|
||||
rssi: -52,
|
||||
freq: 25,
|
||||
drone_id: 1002,
|
||||
timestamp: new Date(Date.now() - 60 * 60 * 1000),
|
||||
threat_level: 'medium',
|
||||
estimated_distance: 800,
|
||||
requires_action: false
|
||||
},
|
||||
{
|
||||
device_id: 1941875381,
|
||||
geo_lat: 59.3295,
|
||||
geo_lon: 18.0690,
|
||||
device_timestamp: Math.floor(Date.now() / 1000) - 7200,
|
||||
drone_type: 0,
|
||||
rssi: -75,
|
||||
freq: 22,
|
||||
drone_id: 1003,
|
||||
timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000),
|
||||
threat_level: 'low',
|
||||
estimated_distance: 2500,
|
||||
requires_action: false
|
||||
}
|
||||
]);
|
||||
console.log(`✅ Created ${detections.length} sample drone detections`);
|
||||
|
||||
// Create sample alert rules
|
||||
console.log('🚨 Creating sample alert rules...');
|
||||
const alertRules = await AlertRule.bulkCreate([
|
||||
{
|
||||
user_id: adminUser.id,
|
||||
name: 'Critical Security Threat',
|
||||
description: 'Immediate alert for critical and high threats to government facilities',
|
||||
conditions: {
|
||||
min_threat_level: 'high',
|
||||
rssi_threshold: -55,
|
||||
max_distance: 200,
|
||||
drone_types: [0, 1, 2],
|
||||
device_ids: []
|
||||
},
|
||||
actions: {
|
||||
sms: true,
|
||||
phone_number: '+46701234567',
|
||||
email: true,
|
||||
channels: ['sms', 'email']
|
||||
},
|
||||
cooldown_minutes: 2,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
user_id: operatorUser.id,
|
||||
name: 'Medium Threat Monitoring',
|
||||
description: 'Monitor medium threat drones in facility vicinity',
|
||||
conditions: {
|
||||
min_threat_level: 'medium',
|
||||
rssi_threshold: -70,
|
||||
max_distance: 1000,
|
||||
drone_types: [1, 2],
|
||||
device_ids: [1941875381, 1941875382]
|
||||
},
|
||||
actions: {
|
||||
sms: true,
|
||||
phone_number: '+46709876543',
|
||||
channels: ['sms']
|
||||
},
|
||||
cooldown_minutes: 10,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
user_id: adminUser.id,
|
||||
name: 'Device Offline Alert',
|
||||
description: 'Alert when security devices go offline',
|
||||
conditions: {
|
||||
device_offline: true,
|
||||
device_ids: [1941875381, 1941875382, 1941875383]
|
||||
},
|
||||
actions: {
|
||||
sms: true,
|
||||
phone_number: '+46701234567',
|
||||
channels: ['sms']
|
||||
},
|
||||
cooldown_minutes: 30,
|
||||
is_active: true
|
||||
}
|
||||
]);
|
||||
console.log(`✅ Created ${alertRules.length} sample alert rules`);
|
||||
|
||||
// Create sample alert logs
|
||||
console.log('📝 Creating sample alert logs...');
|
||||
const alertLogs = await AlertLog.bulkCreate([
|
||||
{
|
||||
user_id: adminUser.id,
|
||||
alert_rule_id: alertRules[0].id,
|
||||
detection_id: detections[0].id,
|
||||
message: 'Drone detected with strong signal',
|
||||
status: 'sent',
|
||||
sent_at: new Date()
|
||||
},
|
||||
{
|
||||
user_id: operatorUser.id,
|
||||
alert_rule_id: alertRules[1].id,
|
||||
detection_id: null,
|
||||
message: 'Device 1941875383 went offline',
|
||||
status: 'sent',
|
||||
sent_at: new Date(Date.now() - 30 * 60 * 1000)
|
||||
}
|
||||
]);
|
||||
console.log(`✅ Created ${alertLogs.length} sample alert logs\n`);
|
||||
|
||||
// Create database indexes for performance
|
||||
console.log('🚀 Creating database indexes...');
|
||||
|
||||
await sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_drone_detections_device_timestamp
|
||||
ON "DroneDetections" (device_id, timestamp);
|
||||
`);
|
||||
|
||||
await sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_heartbeats_device_timestamp
|
||||
ON "Heartbeats" (device_id, timestamp);
|
||||
`);
|
||||
|
||||
await sequelize.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_alert_logs_user_created
|
||||
ON "AlertLogs" (user_id, "createdAt");
|
||||
`);
|
||||
|
||||
console.log('✅ Database indexes created\n');
|
||||
|
||||
console.log('🎉 Database setup completed successfully!\n');
|
||||
console.log('📋 Summary:');
|
||||
console.log(` • Users created: 2 (admin, operator)`);
|
||||
console.log(` • Devices created: ${devices.length}`);
|
||||
console.log(` • Heartbeats created: ${heartbeats.length}`);
|
||||
console.log(` • Detections created: ${detections.length}`);
|
||||
console.log(` • Alert rules created: ${alertRules.length}`);
|
||||
console.log(` • Alert logs created: ${alertLogs.length}`);
|
||||
console.log('\n📝 Default login credentials:');
|
||||
console.log(' Admin: admin / admin123');
|
||||
console.log(' Operator: operator / operator123\n');
|
||||
|
||||
// Close connection
|
||||
await sequelize.close();
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Database setup failed:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Run setup if called directly
|
||||
if (require.main === module) {
|
||||
setupDatabase();
|
||||
}
|
||||
|
||||
module.exports = setupDatabase;
|
||||
499
server/services/alertService.js
Normal file
499
server/services/alertService.js
Normal file
@@ -0,0 +1,499 @@
|
||||
const twilio = require('twilio');
|
||||
const { AlertRule, AlertLog, User, Device } = require('../models');
|
||||
const { Op } = require('sequelize');
|
||||
|
||||
class AlertService {
|
||||
constructor() {
|
||||
this.twilioClient = null;
|
||||
this.initializeTwilio();
|
||||
}
|
||||
|
||||
// RSSI-based threat assessment for security installations
|
||||
assessThreatLevel(rssi, droneType) {
|
||||
// RSSI typically ranges from -30 (very close) to -100 (very far)
|
||||
// For 15km range detection, we need to establish threat zones for sensitive facilities
|
||||
|
||||
let threatLevel = 'low';
|
||||
let estimatedDistance = 0;
|
||||
let description = '';
|
||||
let actionRequired = false;
|
||||
|
||||
// Convert RSSI to estimated distance (rough calculation)
|
||||
// Formula: Distance (m) = 10^((RSSI_at_1m - RSSI) / (10 * n))
|
||||
// Where n = path loss exponent (typically 2-4 for outdoor environments)
|
||||
const rssiAt1m = -30; // Typical RSSI at 1 meter
|
||||
const pathLossExponent = 3; // Outdoor environment with obstacles
|
||||
estimatedDistance = Math.pow(10, (rssiAt1m - rssi) / (10 * pathLossExponent));
|
||||
|
||||
// Threat level assessment based on distance zones for sensitive facilities
|
||||
if (rssi >= -40) {
|
||||
// Very close: 0-50 meters - CRITICAL THREAT
|
||||
threatLevel = 'critical';
|
||||
description = 'IMMEDIATE THREAT: Drone within security perimeter (0-50m)';
|
||||
actionRequired = true;
|
||||
} else if (rssi >= -55) {
|
||||
// Close: 50-200 meters - HIGH THREAT
|
||||
threatLevel = 'high';
|
||||
description = 'HIGH THREAT: Drone approaching facility (50-200m)';
|
||||
actionRequired = true;
|
||||
} else if (rssi >= -70) {
|
||||
// Medium: 200-1000 meters - MEDIUM THREAT
|
||||
threatLevel = 'medium';
|
||||
description = 'MEDIUM THREAT: Drone in facility vicinity (200m-1km)';
|
||||
actionRequired = false;
|
||||
} else if (rssi >= -85) {
|
||||
// Far: 1-5 kilometers - LOW THREAT
|
||||
threatLevel = 'low';
|
||||
description = 'LOW THREAT: Drone detected at distance (1-5km)';
|
||||
actionRequired = false;
|
||||
} else {
|
||||
// Very far: 5-15 kilometers - MONITORING ONLY
|
||||
threatLevel = 'monitoring';
|
||||
description = 'MONITORING: Drone detected at long range (5-15km)';
|
||||
actionRequired = false;
|
||||
}
|
||||
|
||||
// Adjust threat level based on drone type (if classified)
|
||||
const droneTypes = {
|
||||
0: 'Consumer/Hobby',
|
||||
1: 'Professional/Military',
|
||||
2: 'Racing/High-speed',
|
||||
3: 'Unknown/Custom'
|
||||
};
|
||||
|
||||
if (droneType === 1) {
|
||||
// Military/Professional drone - escalate threat
|
||||
if (threatLevel === 'low') threatLevel = 'medium';
|
||||
if (threatLevel === 'medium') threatLevel = 'high';
|
||||
if (threatLevel === 'high') threatLevel = 'critical';
|
||||
description += ' - PROFESSIONAL/MILITARY DRONE DETECTED';
|
||||
actionRequired = true;
|
||||
} else if (droneType === 2) {
|
||||
// Racing/Fast drone - escalate if close
|
||||
if (rssi >= -55 && threatLevel !== 'critical') {
|
||||
threatLevel = 'high';
|
||||
description += ' - HIGH-SPEED DRONE DETECTED';
|
||||
actionRequired = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
level: threatLevel,
|
||||
estimatedDistance: Math.round(estimatedDistance),
|
||||
rssi,
|
||||
droneType: droneTypes[droneType] || 'Unknown',
|
||||
description,
|
||||
requiresImmediateAction: actionRequired,
|
||||
priority: threatLevel === 'critical' ? 1 : threatLevel === 'high' ? 2 : threatLevel === 'medium' ? 3 : 4
|
||||
};
|
||||
}
|
||||
|
||||
initializeTwilio() {
|
||||
if (process.env.TWILIO_ACCOUNT_SID && process.env.TWILIO_AUTH_TOKEN) {
|
||||
this.twilioClient = twilio(
|
||||
process.env.TWILIO_ACCOUNT_SID,
|
||||
process.env.TWILIO_AUTH_TOKEN
|
||||
);
|
||||
} else {
|
||||
console.warn('Twilio credentials not configured. SMS alerts will be disabled.');
|
||||
}
|
||||
}
|
||||
|
||||
async processAlert(detection) {
|
||||
try {
|
||||
console.log(`🔍 Processing alert for detection ${detection.id}`);
|
||||
|
||||
// Assess threat level based on RSSI and drone type
|
||||
const threatAssessment = this.assessThreatLevel(detection.rssi, detection.drone_type);
|
||||
console.log('⚠️ Threat assessment:', threatAssessment);
|
||||
|
||||
// Update detection with threat assessment
|
||||
await detection.update({
|
||||
processed: true,
|
||||
threat_level: threatAssessment.level,
|
||||
estimated_distance: threatAssessment.estimatedDistance
|
||||
});
|
||||
|
||||
// Get all active alert rules
|
||||
const alertRules = await AlertRule.findAll({
|
||||
where: { is_active: true },
|
||||
include: [{
|
||||
model: User,
|
||||
as: 'user',
|
||||
where: { is_active: true }
|
||||
}]
|
||||
});
|
||||
|
||||
for (const rule of alertRules) {
|
||||
if (await this.shouldTriggerAlert(rule, detection, threatAssessment)) {
|
||||
await this.triggerAlert(rule, detection, threatAssessment);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error processing alert:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async shouldTriggerAlert(rule, detection, threatAssessment) {
|
||||
try {
|
||||
// SECURITY ENHANCEMENT: Check threat level requirements
|
||||
if (rule.conditions.min_threat_level) {
|
||||
const threatLevels = { 'monitoring': 0, 'low': 1, 'medium': 2, 'high': 3, 'critical': 4 };
|
||||
const requiredLevel = threatLevels[rule.conditions.min_threat_level] || 0;
|
||||
const currentLevel = threatLevels[threatAssessment.level] || 0;
|
||||
|
||||
if (currentLevel < requiredLevel) {
|
||||
console.log(`Alert rule ${rule.name}: Threat level ${threatAssessment.level} below minimum ${rule.conditions.min_threat_level}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// SECURITY ENHANCEMENT: For government/sensitive sites, always alert on critical threats
|
||||
if (threatAssessment.level === 'critical') {
|
||||
console.log(`🚨 CRITICAL THREAT DETECTED - Force triggering alert for rule ${rule.name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check device filter
|
||||
if (rule.conditions.device_ids && rule.conditions.device_ids.length > 0 &&
|
||||
!rule.conditions.device_ids.includes(detection.device_id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check drone type filter
|
||||
if (rule.conditions.drone_types && rule.conditions.drone_types.length > 0 &&
|
||||
!rule.conditions.drone_types.includes(detection.drone_type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check RSSI thresholds (enhanced for security)
|
||||
if (rule.conditions.rssi_threshold && detection.rssi < rule.conditions.rssi_threshold) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// SECURITY ENHANCEMENT: Check estimated distance
|
||||
if (rule.conditions.max_distance && threatAssessment.estimatedDistance > rule.conditions.max_distance) {
|
||||
console.log(`Alert rule ${rule.name}: Distance ${threatAssessment.estimatedDistance}m exceeds maximum ${rule.conditions.max_distance}m`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check frequency ranges
|
||||
if (rule.frequency_ranges && rule.frequency_ranges.length > 0) {
|
||||
const inRange = rule.frequency_ranges.some(range =>
|
||||
detection.freq >= range.min && detection.freq <= range.max
|
||||
);
|
||||
if (!inRange) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check time window and minimum detections
|
||||
if (rule.min_detections > 1) {
|
||||
const timeWindowStart = new Date(Date.now() - rule.time_window * 1000);
|
||||
const recentDetections = await DroneDetection.count({
|
||||
where: {
|
||||
device_id: detection.device_id,
|
||||
drone_id: detection.drone_id,
|
||||
server_timestamp: {
|
||||
[Op.gte]: timeWindowStart
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (recentDetections < rule.min_detections) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check cooldown period
|
||||
if (rule.cooldown_period > 0) {
|
||||
const cooldownStart = new Date(Date.now() - rule.cooldown_period * 1000);
|
||||
const recentAlert = await AlertLog.findOne({
|
||||
where: {
|
||||
alert_rule_id: rule.id,
|
||||
status: 'sent',
|
||||
sent_at: {
|
||||
[Op.gte]: cooldownStart
|
||||
}
|
||||
},
|
||||
include: [{
|
||||
model: DroneDetection,
|
||||
as: 'detection',
|
||||
where: {
|
||||
device_id: detection.device_id,
|
||||
drone_id: detection.drone_id
|
||||
}
|
||||
}]
|
||||
});
|
||||
|
||||
if (recentAlert) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check active hours and days
|
||||
if (rule.active_hours || rule.active_days) {
|
||||
const now = new Date();
|
||||
const currentHour = now.getHours();
|
||||
const currentMinute = now.getMinutes();
|
||||
const currentTime = currentHour * 60 + currentMinute;
|
||||
const currentDay = now.getDay() || 7; // Convert Sunday from 0 to 7
|
||||
|
||||
if (rule.active_days && !rule.active_days.includes(currentDay)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rule.active_hours) {
|
||||
const startTime = this.parseTime(rule.active_hours.start);
|
||||
const endTime = this.parseTime(rule.active_hours.end);
|
||||
|
||||
if (startTime !== null && endTime !== null) {
|
||||
if (startTime <= endTime) {
|
||||
// Same day range
|
||||
if (currentTime < startTime || currentTime > endTime) {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
// Overnight range
|
||||
if (currentTime < startTime && currentTime > endTime) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error checking alert conditions:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async triggerAlert(rule, detection, threatAssessment) {
|
||||
try {
|
||||
const user = rule.user;
|
||||
const device = await Device.findByPk(detection.device_id);
|
||||
|
||||
// Generate enhanced alert message with threat assessment
|
||||
const message = this.generateSecurityAlertMessage(detection, device, rule, threatAssessment);
|
||||
|
||||
// SECURITY ENHANCEMENT: For critical threats, send to all available channels
|
||||
const channels = threatAssessment.level === 'critical'
|
||||
? ['sms', 'email', 'webhook'] // Force all channels for critical threats
|
||||
: rule.actions.channels || ['sms'];
|
||||
|
||||
// Send alerts through configured channels
|
||||
for (const channel of channels) {
|
||||
let alertLog = null;
|
||||
|
||||
try {
|
||||
switch (channel) {
|
||||
case 'sms':
|
||||
if (rule.actions.sms && rule.actions.phone_number) {
|
||||
alertLog = await this.sendSMSAlert(rule.actions.phone_number, message, rule, detection, threatAssessment);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'email':
|
||||
if (rule.actions.email && user.email) {
|
||||
alertLog = await this.sendEmailAlert(user.email, message, rule, detection, threatAssessment);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'webhook':
|
||||
if (rule.actions.webhook_url) {
|
||||
alertLog = await this.sendWebhookAlert(rule.actions.webhook_url, detection, device, rule, threatAssessment);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
console.warn(`Unknown alert channel: ${channel}`);
|
||||
}
|
||||
|
||||
if (alertLog) {
|
||||
console.log(`🚨 ${threatAssessment.level.toUpperCase()} THREAT: Alert sent via ${channel} for detection ${detection.id}`);
|
||||
}
|
||||
|
||||
} catch (channelError) {
|
||||
console.error(`Error sending ${channel} alert:`, channelError);
|
||||
|
||||
// Log failed alert
|
||||
await AlertLog.create({
|
||||
alert_rule_id: rule.id,
|
||||
detection_id: detection.id,
|
||||
alert_type: channel,
|
||||
recipient: channel === 'sms' ? user.phone_number :
|
||||
channel === 'email' ? user.email : rule.webhook_url,
|
||||
message: message,
|
||||
status: 'failed',
|
||||
error_message: channelError.message,
|
||||
priority: rule.priority
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error triggering alert:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendSMSAlert(phoneNumber, message, rule, detection) {
|
||||
if (!this.twilioClient) {
|
||||
throw new Error('Twilio not configured');
|
||||
}
|
||||
|
||||
const twilioMessage = await this.twilioClient.messages.create({
|
||||
body: message,
|
||||
from: process.env.TWILIO_PHONE_NUMBER,
|
||||
to: phoneNumber
|
||||
});
|
||||
|
||||
return await AlertLog.create({
|
||||
alert_rule_id: rule.id,
|
||||
detection_id: detection.id,
|
||||
alert_type: 'sms',
|
||||
recipient: phoneNumber,
|
||||
message: message,
|
||||
status: 'sent',
|
||||
sent_at: new Date(),
|
||||
external_id: twilioMessage.sid,
|
||||
priority: rule.priority
|
||||
});
|
||||
}
|
||||
|
||||
async sendEmailAlert(email, message, rule, detection) {
|
||||
// Email implementation would go here
|
||||
// For now, just log the alert
|
||||
console.log(`Email alert would be sent to ${email}: ${message}`);
|
||||
|
||||
return await AlertLog.create({
|
||||
alert_rule_id: rule.id,
|
||||
detection_id: detection.id,
|
||||
alert_type: 'email',
|
||||
recipient: email,
|
||||
message: message,
|
||||
status: 'sent',
|
||||
sent_at: new Date(),
|
||||
priority: rule.priority
|
||||
});
|
||||
}
|
||||
|
||||
async sendWebhookAlert(webhookUrl, detection, device, rule) {
|
||||
const payload = {
|
||||
event: 'drone_detection',
|
||||
timestamp: new Date().toISOString(),
|
||||
detection: {
|
||||
id: detection.id,
|
||||
device_id: detection.device_id,
|
||||
drone_id: detection.drone_id,
|
||||
drone_type: detection.drone_type,
|
||||
rssi: detection.rssi,
|
||||
freq: detection.freq,
|
||||
geo_lat: detection.geo_lat,
|
||||
geo_lon: detection.geo_lon,
|
||||
server_timestamp: detection.server_timestamp
|
||||
},
|
||||
device: {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
geo_lat: device.geo_lat,
|
||||
geo_lon: device.geo_lon,
|
||||
location_description: device.location_description
|
||||
},
|
||||
alert_rule: {
|
||||
id: rule.id,
|
||||
name: rule.name,
|
||||
priority: rule.priority
|
||||
}
|
||||
};
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'DroneDetectionSystem/1.0'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
return await AlertLog.create({
|
||||
alert_rule_id: rule.id,
|
||||
detection_id: detection.id,
|
||||
alert_type: 'webhook',
|
||||
recipient: webhookUrl,
|
||||
message: JSON.stringify(payload),
|
||||
status: 'sent',
|
||||
sent_at: new Date(),
|
||||
priority: rule.priority
|
||||
});
|
||||
}
|
||||
|
||||
generateAlertMessage(detection, device, rule) {
|
||||
const deviceName = device.name || `Device ${device.id}`;
|
||||
const location = device.location_description ||
|
||||
`${device.geo_lat}, ${device.geo_lon}` ||
|
||||
'Unknown location';
|
||||
|
||||
return `🚨 DRONE ALERT: Drone detected by ${deviceName} at ${location}. ` +
|
||||
`Drone ID: ${detection.drone_id}, Frequency: ${detection.freq}MHz, ` +
|
||||
`RSSI: ${detection.rssi}dBm. Time: ${new Date().toLocaleString()}`;
|
||||
}
|
||||
|
||||
// SECURITY ENHANCEMENT: Enhanced message generation with threat assessment
|
||||
generateSecurityAlertMessage(detection, device, rule, threatAssessment) {
|
||||
const timestamp = new Date().toLocaleString('sv-SE', { timeZone: 'Europe/Stockholm' });
|
||||
const deviceName = device ? device.name : `Device ${detection.device_id}`;
|
||||
const location = device ? device.location : 'Unknown location';
|
||||
|
||||
// Create security-focused alert message
|
||||
let message = `🚨 SECURITY ALERT 🚨\n`;
|
||||
message += `THREAT LEVEL: ${threatAssessment.level.toUpperCase()}\n`;
|
||||
message += `${threatAssessment.description}\n\n`;
|
||||
message += `📍 LOCATION: ${location}\n`;
|
||||
message += `🔧 DEVICE: ${deviceName}\n`;
|
||||
message += `📏 DISTANCE: ~${threatAssessment.estimatedDistance}m\n`;
|
||||
message += `📶 SIGNAL: ${detection.rssi} dBm\n`;
|
||||
message += `🚁 DRONE TYPE: ${threatAssessment.droneType}\n`;
|
||||
message += `⏰ TIME: ${timestamp}\n`;
|
||||
|
||||
if (threatAssessment.requiresImmediateAction) {
|
||||
message += `\n⚠️ IMMEDIATE ACTION REQUIRED\n`;
|
||||
message += `Security personnel should respond immediately.`;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
parseTime(timeString) {
|
||||
if (!timeString) return null;
|
||||
|
||||
const match = timeString.match(/^(\d{1,2}):(\d{2})$/);
|
||||
if (!match) return null;
|
||||
|
||||
const hours = parseInt(match[1]);
|
||||
const minutes = parseInt(match[2]);
|
||||
|
||||
if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
const alertService = new AlertService();
|
||||
|
||||
module.exports = {
|
||||
processAlert: (detection) => alertService.processAlert(detection),
|
||||
AlertService
|
||||
};
|
||||
55
server/services/socketService.js
Normal file
55
server/services/socketService.js
Normal file
@@ -0,0 +1,55 @@
|
||||
function initializeSocketHandlers(io) {
|
||||
io.on('connection', (socket) => {
|
||||
console.log(`Client connected: ${socket.id}`);
|
||||
|
||||
// Join device-specific rooms for targeted updates
|
||||
socket.on('join_device_room', (deviceId) => {
|
||||
socket.join(`device_${deviceId}`);
|
||||
console.log(`Client ${socket.id} joined device room: device_${deviceId}`);
|
||||
});
|
||||
|
||||
// Join dashboard room for general updates
|
||||
socket.on('join_dashboard', () => {
|
||||
socket.join('dashboard');
|
||||
console.log(`Client ${socket.id} joined dashboard room`);
|
||||
});
|
||||
|
||||
// Leave rooms
|
||||
socket.on('leave_device_room', (deviceId) => {
|
||||
socket.leave(`device_${deviceId}`);
|
||||
console.log(`Client ${socket.id} left device room: device_${deviceId}`);
|
||||
});
|
||||
|
||||
socket.on('leave_dashboard', () => {
|
||||
socket.leave('dashboard');
|
||||
console.log(`Client ${socket.id} left dashboard room`);
|
||||
});
|
||||
|
||||
// Handle client disconnect
|
||||
socket.on('disconnect', () => {
|
||||
console.log(`Client disconnected: ${socket.id}`);
|
||||
});
|
||||
|
||||
// Send current status on connect
|
||||
socket.emit('connection_status', {
|
||||
status: 'connected',
|
||||
timestamp: new Date().toISOString(),
|
||||
clientId: socket.id
|
||||
});
|
||||
});
|
||||
|
||||
// Helper functions to emit events to specific rooms
|
||||
io.emitToDevice = function(deviceId, event, data) {
|
||||
io.to(`device_${deviceId}`).emit(event, data);
|
||||
};
|
||||
|
||||
io.emitToDashboard = function(event, data) {
|
||||
io.to('dashboard').emit(event, data);
|
||||
};
|
||||
|
||||
console.log('Socket.IO handlers initialized');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeSocketHandlers
|
||||
};
|
||||
Reference in New Issue
Block a user