Initial commit
This commit is contained in:
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