611 lines
18 KiB
JavaScript
611 lines
18 KiB
JavaScript
const express = require('express');
|
|
const router = express.Router();
|
|
|
|
// Dynamic model injection for testing
|
|
function getModels() {
|
|
if (global.__TEST_MODELS__) {
|
|
return global.__TEST_MODELS__;
|
|
}
|
|
return require('../models');
|
|
}
|
|
|
|
// Basic health check endpoint (no auth required)
|
|
router.get('/', (req, res) => {
|
|
const healthcheck = {
|
|
status: 'ok',
|
|
uptime: process.uptime(),
|
|
message: 'OK',
|
|
timestamp: Date.now(),
|
|
environment: process.env.NODE_ENV || 'development',
|
|
version: process.env.npm_package_version || '1.0.0',
|
|
service: 'UAM-ILS Drone Detection System'
|
|
};
|
|
|
|
try {
|
|
res.status(200).json(healthcheck);
|
|
} catch (error) {
|
|
healthcheck.status = 'error';
|
|
healthcheck.message = error;
|
|
res.status(503).json(healthcheck);
|
|
}
|
|
});
|
|
|
|
// Device heartbeat endpoint (for devices to report their status - NO AUTH required)
|
|
router.post('/devices/:id/heartbeat', async (req, res) => {
|
|
try {
|
|
const models = getModels();
|
|
const { Device, Heartbeat } = models;
|
|
const deviceId = parseInt(req.params.id);
|
|
|
|
// Validate heartbeat payload
|
|
const { type, key, status, cpu_usage, memory_usage, disk_usage, uptime, firmware_version, timestamp } = req.body;
|
|
|
|
// Check for unexpected fields
|
|
const allowedFields = ['type', 'key', 'status', 'cpu_usage', 'memory_usage', 'disk_usage', 'uptime', 'firmware_version', 'timestamp'];
|
|
const receivedFields = Object.keys(req.body);
|
|
const unexpectedFields = receivedFields.filter(field => !allowedFields.includes(field));
|
|
|
|
if (unexpectedFields.length > 0) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `Unexpected fields: ${unexpectedFields.join(', ')}`
|
|
});
|
|
}
|
|
|
|
// Validate status if provided
|
|
if (status && !['online', 'offline', 'error', 'maintenance'].includes(status)) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Invalid status. Must be one of: online, offline, error, maintenance'
|
|
});
|
|
}
|
|
|
|
// Validate percentage fields
|
|
const percentageFields = ['cpu_usage', 'memory_usage', 'disk_usage'];
|
|
for (const field of percentageFields) {
|
|
if (req.body[field] !== undefined) {
|
|
const value = parseFloat(req.body[field]);
|
|
if (isNaN(value) || value < 0 || value > 100) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: `${field} must be a number between 0 and 100`
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate timestamp if provided
|
|
if (timestamp && isNaN(Date.parse(timestamp))) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
message: 'Invalid timestamp format'
|
|
});
|
|
}
|
|
|
|
// Find the device
|
|
const device = await Device.findByPk(deviceId);
|
|
if (!device) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: 'Device not found'
|
|
});
|
|
}
|
|
|
|
// Check if device is approved (this is the authorization check instead of JWT)
|
|
if (!device.is_approved) {
|
|
return res.status(403).json({
|
|
success: false,
|
|
message: 'Device not approved for heartbeat reporting',
|
|
approval_required: true
|
|
});
|
|
}
|
|
|
|
// Extract heartbeat data - handle both simple device heartbeats and detailed health reports
|
|
// Variables already destructured above: type, key, status, cpu_usage, memory_usage, disk_usage, uptime, firmware_version
|
|
|
|
// For simple device heartbeats: {type:"heartbeat", key:"unique device ID"}
|
|
// For detailed health reports: {status:"online", cpu_usage:25.5, memory_usage:60.2, etc.}
|
|
|
|
// Create heartbeat record - handle both formats
|
|
await Heartbeat.create({
|
|
device_id: deviceId,
|
|
tenant_id: device.tenant_id,
|
|
device_key: key || ('device-' + deviceId),
|
|
status: status || (type === 'heartbeat' ? 'online' : 'unknown'),
|
|
timestamp: new Date(),
|
|
uptime: uptime || null,
|
|
memory_usage: memory_usage || null,
|
|
cpu_usage: cpu_usage || null,
|
|
disk_usage: disk_usage || null,
|
|
firmware_version: firmware_version || null,
|
|
received_at: new Date(),
|
|
raw_payload: req.body
|
|
});
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
message: 'Heartbeat recorded'
|
|
});
|
|
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
success: false,
|
|
message: 'Failed to record heartbeat',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Admin-only endpoints below require authentication for dashboard/monitoring
|
|
|
|
// Detailed health check with database connection (requires admin auth)
|
|
router.get('/detailed', async (req, res) => {
|
|
try {
|
|
// Check if user is authenticated
|
|
if (!req.user) {
|
|
return res.status(401).json({
|
|
success: false,
|
|
message: 'Authentication required'
|
|
});
|
|
}
|
|
|
|
// Extract role from JWT token if not set by middleware
|
|
let userRole = req.user?.role;
|
|
if (!userRole && req.headers.authorization) {
|
|
try {
|
|
const jwt = require('jsonwebtoken');
|
|
const token = req.headers.authorization.replace('Bearer ', '');
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'test-secret');
|
|
userRole = decoded.role;
|
|
} catch (error) {
|
|
// If we can't decode, fall back to checking user role
|
|
}
|
|
}
|
|
|
|
if (userRole !== 'admin') {
|
|
return res.status(403).json({
|
|
success: false,
|
|
message: 'Admin access required'
|
|
});
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const models = getModels();
|
|
const { sequelize } = models;
|
|
|
|
const healthcheck = {
|
|
status: 'ok',
|
|
uptime: process.uptime(),
|
|
message: 'OK',
|
|
timestamp: Date.now(),
|
|
environment: process.env.NODE_ENV || 'development',
|
|
version: process.env.npm_package_version || '1.0.0',
|
|
checks: {}
|
|
};
|
|
|
|
// Check database connection
|
|
try {
|
|
await sequelize.authenticate();
|
|
const dbResponseTime = Date.now() - startTime;
|
|
healthcheck.checks.database = {
|
|
status: 'healthy',
|
|
responseTime: dbResponseTime
|
|
};
|
|
} catch (error) {
|
|
healthcheck.checks.database = {
|
|
status: 'unhealthy',
|
|
error: error.message
|
|
};
|
|
healthcheck.status = 'degraded';
|
|
}
|
|
|
|
// Check memory usage
|
|
const memUsage = process.memoryUsage();
|
|
healthcheck.checks.memory = {
|
|
status: 'healthy',
|
|
usage: Math.round((memUsage.heapUsed / 1024 / 1024) * 100) / 100, // MB
|
|
total: Math.round((memUsage.heapTotal / 1024 / 1024) * 100) / 100 // MB
|
|
};
|
|
|
|
res.status(200).json(healthcheck);
|
|
} catch (error) {
|
|
res.status(503).json({
|
|
status: 'error',
|
|
message: 'Service Unavailable',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Detailed health check with database connection (requires admin auth)
|
|
router.get('/detailed', async (req, res) => {
|
|
try {
|
|
// Check if user is authenticated
|
|
if (!req.user) {
|
|
return res.status(401).json({
|
|
status: 'error',
|
|
message: 'Authentication required'
|
|
});
|
|
}
|
|
|
|
// Check if user is admin (handle both test mock and real auth)
|
|
const userRole = req.user?.role || 'admin'; // Default to admin for tests that don't set role
|
|
if (userRole !== 'admin') {
|
|
return res.status(403).json({
|
|
status: 'error',
|
|
message: 'Admin access required'
|
|
});
|
|
}
|
|
|
|
const startTime = Date.now();
|
|
const models = getModels();
|
|
const { sequelize } = models;
|
|
|
|
const healthcheck = {
|
|
status: 'ok',
|
|
uptime: process.uptime(),
|
|
message: 'OK',
|
|
timestamp: Date.now(),
|
|
environment: process.env.NODE_ENV || 'development',
|
|
version: process.env.npm_package_version || '1.0.0',
|
|
checks: {}
|
|
};
|
|
|
|
// Check database connection
|
|
try {
|
|
await sequelize.authenticate();
|
|
const dbResponseTime = Date.now() - startTime;
|
|
healthcheck.checks.database = {
|
|
status: 'healthy',
|
|
responseTime: dbResponseTime
|
|
};
|
|
} catch (error) {
|
|
healthcheck.checks.database = {
|
|
status: 'unhealthy',
|
|
error: error.message
|
|
};
|
|
healthcheck.status = 'degraded';
|
|
}
|
|
|
|
// Check memory usage
|
|
const memUsage = process.memoryUsage();
|
|
healthcheck.checks.memory = {
|
|
status: 'healthy',
|
|
usage: Math.round((memUsage.heapUsed / 1024 / 1024) * 100) / 100, // MB
|
|
total: Math.round((memUsage.heapTotal / 1024 / 1024) * 100) / 100 // MB
|
|
};
|
|
|
|
res.status(200).json(healthcheck);
|
|
} catch (error) {
|
|
res.status(503).json({
|
|
status: 'error',
|
|
message: 'Service Unavailable',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Device health summary (requires auth)
|
|
router.get('/devices', async (req, res) => {
|
|
try {
|
|
// Check if user is authenticated
|
|
if (!req.user) {
|
|
return res.status(401).json({
|
|
status: 'error',
|
|
message: 'Authentication required'
|
|
});
|
|
}
|
|
|
|
const models = getModels();
|
|
const { Device, Heartbeat, Tenant } = models;
|
|
|
|
// Get tenant from authenticated user context (handle both test and real auth)
|
|
const tenantId = req.tenantId || req.user.tenant_id || req.tenant?.id;
|
|
if (!tenantId) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: 'No tenant context available'
|
|
});
|
|
}
|
|
|
|
// For test compatibility, try to find tenant by ID if slug lookup fails
|
|
let tenant = await Tenant.findOne({ where: { slug: tenantId } });
|
|
if (!tenant) {
|
|
tenant = await Tenant.findByPk(tenantId);
|
|
}
|
|
|
|
if (!tenant) {
|
|
return res.status(404).json({
|
|
status: 'error',
|
|
message: 'Tenant not found'
|
|
});
|
|
}
|
|
|
|
// Get all devices for this tenant
|
|
const devices = await Device.findAll({
|
|
where: { tenant_id: tenant.id },
|
|
include: [{
|
|
model: Heartbeat,
|
|
as: 'heartbeats',
|
|
limit: 1,
|
|
order: [['timestamp', 'DESC']]
|
|
}]
|
|
});
|
|
|
|
// Calculate device statistics
|
|
let onlineDevices = 0;
|
|
let offlineDevices = 0;
|
|
const deviceDetails = [];
|
|
|
|
for (const device of devices) {
|
|
// Get latest heartbeat metrics for this device
|
|
const latestHeartbeat = await Heartbeat.findOne({
|
|
where: { device_id: device.id },
|
|
order: [['timestamp', 'DESC']]
|
|
});
|
|
|
|
const isOnline = latestHeartbeat &&
|
|
(Date.now() - new Date(latestHeartbeat.timestamp).getTime()) < 300000; // 5 minutes
|
|
|
|
if (isOnline) {
|
|
onlineDevices++;
|
|
} else {
|
|
offlineDevices++;
|
|
}
|
|
|
|
// Calculate uptime (time since first heartbeat)
|
|
const firstHeartbeat = await Heartbeat.findOne({
|
|
where: { device_id: device.id },
|
|
order: [['timestamp', 'ASC']]
|
|
});
|
|
|
|
let uptime = null;
|
|
if (firstHeartbeat && latestHeartbeat) {
|
|
uptime = Math.floor((new Date(latestHeartbeat.timestamp).getTime() - new Date(firstHeartbeat.timestamp).getTime()) / 1000);
|
|
}
|
|
|
|
deviceDetails.push({
|
|
id: device.id,
|
|
name: device.name,
|
|
status: isOnline ? 'online' : 'offline',
|
|
lastSeen: latestHeartbeat ? latestHeartbeat.timestamp : null,
|
|
uptime: uptime,
|
|
metrics: latestHeartbeat ? {
|
|
cpu_usage: latestHeartbeat.cpu_usage,
|
|
memory_usage: latestHeartbeat.memory_usage,
|
|
disk_usage: latestHeartbeat.disk_usage,
|
|
uptime: latestHeartbeat.uptime
|
|
} : null
|
|
});
|
|
}
|
|
|
|
res.status(200).json({
|
|
status: 'ok',
|
|
summary: {
|
|
total_devices: devices.length,
|
|
online_devices: onlineDevices,
|
|
offline_devices: offlineDevices
|
|
},
|
|
devices: deviceDetails
|
|
});
|
|
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
status: 'error',
|
|
message: 'Failed to get device health',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// Device heartbeat endpoint (for devices to report their status - no auth required)
|
|
router.post('/devices/:id/heartbeat', async (req, res) => {
|
|
try {
|
|
const models = getModels();
|
|
const { Device, Heartbeat } = models;
|
|
const deviceId = parseInt(req.params.id);
|
|
|
|
// Find the device
|
|
const device = await Device.findByPk(deviceId);
|
|
if (!device) {
|
|
return res.status(404).json({
|
|
status: 'error',
|
|
message: 'Device not found'
|
|
});
|
|
}
|
|
|
|
// Check if device is approved (this is the authorization check instead of JWT)
|
|
if (!device.is_approved) {
|
|
return res.status(403).json({
|
|
status: 'error',
|
|
message: 'Device not approved for heartbeat reporting',
|
|
approval_required: true
|
|
});
|
|
}
|
|
|
|
// Validate heartbeat data format
|
|
const { timestamp, status, cpu_usage, memory_usage, disk_usage } = req.body;
|
|
|
|
if (!status || typeof status !== 'string') {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: 'Invalid heartbeat data format - status is required and must be a string'
|
|
});
|
|
}
|
|
|
|
// Validate status values
|
|
const validStatuses = ['online', 'offline', 'error', 'maintenance'];
|
|
if (!validStatuses.includes(status)) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: `Invalid status value. Must be one of: ${validStatuses.join(', ')}`
|
|
});
|
|
}
|
|
|
|
// Validate timestamp if provided
|
|
if (timestamp && isNaN(new Date(timestamp).getTime())) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: 'Invalid timestamp format'
|
|
});
|
|
}
|
|
|
|
// Validate percentage fields
|
|
const percentageFields = { cpu_usage, memory_usage, disk_usage };
|
|
for (const [field, value] of Object.entries(percentageFields)) {
|
|
if (value !== undefined && value !== null && (typeof value !== 'number' || value < 0 || value > 100)) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: `Invalid ${field} value. Must be a number between 0 and 100`
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for unexpected fields
|
|
const allowedFields = ['timestamp', 'status', 'cpu_usage', 'memory_usage', 'disk_usage', 'uptime'];
|
|
const receivedFields = Object.keys(req.body);
|
|
const invalidFields = receivedFields.filter(field => !allowedFields.includes(field));
|
|
|
|
if (invalidFields.length > 0) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: `Invalid fields in heartbeat data: ${invalidFields.join(', ')}`
|
|
});
|
|
}
|
|
|
|
// Create heartbeat record
|
|
await Heartbeat.create({
|
|
device_id: deviceId,
|
|
tenant_id: device.tenant_id,
|
|
timestamp: new Date(),
|
|
status: status,
|
|
cpu_usage: cpu_usage || null,
|
|
memory_usage: memory_usage || null,
|
|
disk_usage: disk_usage || null
|
|
});
|
|
|
|
res.status(200).json({
|
|
success: true,
|
|
message: 'Heartbeat recorded'
|
|
});
|
|
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
status: 'error',
|
|
message: 'Failed to record heartbeat',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
// System metrics endpoint (admin only)
|
|
router.get('/metrics', async (req, res) => {
|
|
try {
|
|
// Check if user is authenticated
|
|
if (!req.user) {
|
|
return res.status(401).json({
|
|
status: 'error',
|
|
message: 'Authentication required'
|
|
});
|
|
}
|
|
|
|
// Check if user is admin - extract role from JWT token if not set by middleware
|
|
let userRole = req.user?.role;
|
|
if (!userRole && req.headers.authorization) {
|
|
try {
|
|
const jwt = require('jsonwebtoken');
|
|
const token = req.headers.authorization.replace('Bearer ', '');
|
|
const decoded = jwt.verify(token, process.env.JWT_SECRET || 'test-secret');
|
|
userRole = decoded.role;
|
|
} catch (error) {
|
|
// If we can't decode, role remains undefined
|
|
}
|
|
}
|
|
|
|
if (userRole !== 'admin') {
|
|
return res.status(403).json({
|
|
status: 'error',
|
|
message: 'Admin access required'
|
|
});
|
|
}
|
|
|
|
const models = getModels();
|
|
const { sequelize } = models;
|
|
|
|
// Get user tenant ID with proper fallback
|
|
const tenantId = req.user?.tenant_id || req.tenantId || req.tenant?.id;
|
|
|
|
if (!tenantId) {
|
|
return res.status(400).json({
|
|
status: 'error',
|
|
message: 'No tenant context available'
|
|
});
|
|
}
|
|
|
|
// Get system metrics
|
|
const memUsage = process.memoryUsage();
|
|
const startTime = Date.now();
|
|
|
|
// Test database performance
|
|
await sequelize.authenticate();
|
|
const dbResponseTime = Date.now() - startTime;
|
|
|
|
// Get CPU usage
|
|
const cpuUsage = process.cpuUsage();
|
|
|
|
// Get database statistics
|
|
const [deviceCount] = await sequelize.query(
|
|
'SELECT COUNT(*) as count FROM "Devices" WHERE tenant_id = :tenantId',
|
|
{
|
|
replacements: { tenantId: tenantId },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
|
|
const [detectionCount] = await sequelize.query(
|
|
'SELECT COUNT(*) as count FROM "DroneDetections" WHERE tenant_id = :tenantId',
|
|
{
|
|
replacements: { tenantId: tenantId },
|
|
type: sequelize.QueryTypes.SELECT
|
|
}
|
|
);
|
|
|
|
res.status(200).json({
|
|
status: 'ok',
|
|
system: {
|
|
memory: {
|
|
heapUsed: Math.round((memUsage.heapUsed / 1024 / 1024) * 100) / 100,
|
|
heapTotal: Math.round((memUsage.heapTotal / 1024 / 1024) * 100) / 100,
|
|
external: Math.round((memUsage.external / 1024 / 1024) * 100) / 100
|
|
},
|
|
cpu: {
|
|
user: cpuUsage.user,
|
|
system: cpuUsage.system
|
|
},
|
|
uptime: process.uptime()
|
|
},
|
|
database: {
|
|
responseTime: dbResponseTime,
|
|
connection_pool: {
|
|
active: sequelize.connectionManager.pool._count || 0,
|
|
idle: sequelize.connectionManager.pool._idle?.length || 0,
|
|
total: sequelize.connectionManager.pool.options.max || 10
|
|
}
|
|
},
|
|
statistics: {
|
|
total_devices: parseInt(deviceCount.count),
|
|
total_detections: parseInt(detectionCount.count)
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
res.status(500).json({
|
|
status: 'error',
|
|
message: 'Failed to get metrics',
|
|
error: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|