378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
const { Device, AlertRule, AlertLog, Heartbeat } = require('../models');
|
|
const { Op } = require('sequelize');
|
|
|
|
class DeviceHealthService {
|
|
constructor() {
|
|
this.isRunning = false;
|
|
this.checkInterval = 5 * 60 * 1000; // Check every 5 minutes
|
|
this.offlineThreshold = 30 * 60 * 1000; // 30 minutes without heartbeat = offline
|
|
this.offlineDevices = new Map(); // Track devices that are already reported offline
|
|
this.intervalId = null;
|
|
}
|
|
|
|
start() {
|
|
if (this.isRunning) {
|
|
console.log('⚡ Device Health Service is already running');
|
|
return;
|
|
}
|
|
|
|
console.log('⚡ Starting Device Health Monitoring Service');
|
|
console.log(` Check Interval: ${this.checkInterval / 60000} minutes`);
|
|
console.log(` Offline Threshold: ${this.offlineThreshold / 60000} minutes`);
|
|
|
|
this.isRunning = true;
|
|
|
|
// Run initial check
|
|
this.checkDeviceHealth();
|
|
|
|
// Schedule periodic checks
|
|
this.intervalId = setInterval(() => {
|
|
this.checkDeviceHealth();
|
|
}, this.checkInterval);
|
|
}
|
|
|
|
stop() {
|
|
if (!this.isRunning) {
|
|
console.log('⚡ Device Health Service is not running');
|
|
return;
|
|
}
|
|
|
|
console.log('⚡ Stopping Device Health Monitoring Service');
|
|
this.isRunning = false;
|
|
|
|
if (this.intervalId) {
|
|
clearInterval(this.intervalId);
|
|
this.intervalId = null;
|
|
}
|
|
}
|
|
|
|
async checkDeviceHealth() {
|
|
try {
|
|
console.log('🔍 Checking device health status...');
|
|
|
|
const now = new Date();
|
|
const activeDevices = await Device.findAll({
|
|
where: {
|
|
is_active: true,
|
|
is_approved: true
|
|
},
|
|
include: [{
|
|
model: Heartbeat,
|
|
as: 'heartbeats',
|
|
limit: 1,
|
|
order: [['received_at', 'DESC']]
|
|
}]
|
|
});
|
|
|
|
let onlineCount = 0;
|
|
let offlineCount = 0;
|
|
let newlyOfflineDevices = [];
|
|
let recoveredDevices = [];
|
|
|
|
for (const device of activeDevices) {
|
|
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);
|
|
const isOfflineForAlert = timeSinceLastHeartbeat && timeSinceLastHeartbeat > (this.offlineThreshold / 1000);
|
|
|
|
if (isOnline) {
|
|
onlineCount++;
|
|
|
|
// Check if device was previously offline and is now recovered
|
|
if (this.offlineDevices.has(device.id)) {
|
|
recoveredDevices.push(device);
|
|
this.offlineDevices.delete(device.id);
|
|
}
|
|
} else if (isOfflineForAlert) {
|
|
offlineCount++;
|
|
|
|
// Check if this is a newly offline device
|
|
if (!this.offlineDevices.has(device.id)) {
|
|
newlyOfflineDevices.push(device);
|
|
this.offlineDevices.set(device.id, {
|
|
device,
|
|
offlineSince: new Date(device.last_heartbeat),
|
|
alertSent: false
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`📊 Device Health Summary: ${onlineCount} online, ${offlineCount} offline`);
|
|
|
|
if (newlyOfflineDevices.length > 0) {
|
|
console.log(`🚨 Found ${newlyOfflineDevices.length} newly offline devices`);
|
|
await this.handleOfflineDevices(newlyOfflineDevices);
|
|
}
|
|
|
|
if (recoveredDevices.length > 0) {
|
|
console.log(`✅ Found ${recoveredDevices.length} recovered devices`);
|
|
await this.handleRecoveredDevices(recoveredDevices);
|
|
}
|
|
|
|
// Clean up old offline devices that are no longer active/approved
|
|
this.cleanupOfflineDevices(activeDevices);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Error checking device health:', error);
|
|
}
|
|
}
|
|
|
|
async handleOfflineDevices(offlineDevices) {
|
|
for (const device of offlineDevices) {
|
|
try {
|
|
// Find all active alert rules that monitor device offline events
|
|
const deviceOfflineRules = await AlertRule.findAll({
|
|
where: {
|
|
is_active: true,
|
|
[Op.or]: [
|
|
// Rules specifically for device offline monitoring
|
|
{ conditions: { device_offline: true } },
|
|
// Rules that include this specific device
|
|
{
|
|
[Op.and]: [
|
|
{ conditions: { device_ids: { [Op.contains]: [device.id] } } },
|
|
{ conditions: { device_offline: true } }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
});
|
|
|
|
if (deviceOfflineRules.length === 0) {
|
|
console.log(`⚠️ No offline alert rules configured for device ${device.id} (${device.name})`);
|
|
continue;
|
|
}
|
|
|
|
// Trigger alerts for each matching rule
|
|
for (const rule of deviceOfflineRules) {
|
|
await this.triggerDeviceOfflineAlert(rule, device);
|
|
}
|
|
|
|
// Mark as alert sent
|
|
if (this.offlineDevices.has(device.id)) {
|
|
this.offlineDevices.get(device.id).alertSent = true;
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Error handling offline device ${device.id}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async handleRecoveredDevices(recoveredDevices) {
|
|
for (const device of recoveredDevices) {
|
|
try {
|
|
// Find all active alert rules for this device
|
|
const deviceOfflineRules = await AlertRule.findAll({
|
|
where: {
|
|
is_active: true,
|
|
[Op.or]: [
|
|
{ conditions: { device_offline: true } },
|
|
{
|
|
[Op.and]: [
|
|
{ conditions: { device_ids: { [Op.contains]: [device.id] } } },
|
|
{ conditions: { device_offline: true } }
|
|
]
|
|
}
|
|
]
|
|
}
|
|
});
|
|
|
|
// Send recovery notifications
|
|
for (const rule of deviceOfflineRules) {
|
|
await this.triggerDeviceRecoveryAlert(rule, device);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Error handling recovered device ${device.id}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
async triggerDeviceOfflineAlert(rule, device) {
|
|
try {
|
|
const AlertService = require('./alertService');
|
|
const alertService = new AlertService();
|
|
|
|
const timeSinceLastHeartbeat = device.last_heartbeat
|
|
? Math.floor((new Date() - new Date(device.last_heartbeat)) / 1000 / 60)
|
|
: 'unknown';
|
|
|
|
// Generate offline alert message
|
|
const message = this.generateOfflineMessage(device, timeSinceLastHeartbeat);
|
|
|
|
// Send alerts through configured channels
|
|
const channels = rule.alert_channels || ['sms'];
|
|
|
|
for (const channel of channels) {
|
|
try {
|
|
switch (channel) {
|
|
case 'sms':
|
|
if (rule.sms_phone_number) {
|
|
await alertService.sendSMSAlert(rule.sms_phone_number, message, rule, null);
|
|
console.log(`📱 Device offline SMS alert sent for ${device.name} to ${rule.sms_phone_number}`);
|
|
}
|
|
break;
|
|
|
|
case 'email':
|
|
if (rule.email || rule.user?.email) {
|
|
await alertService.sendEmailAlert(rule.email || rule.user.email, message, rule, null);
|
|
console.log(`📧 Device offline email alert sent for ${device.name}`);
|
|
}
|
|
break;
|
|
|
|
case 'webhook':
|
|
if (rule.webhook_url) {
|
|
const webhookData = {
|
|
type: 'device_offline',
|
|
device: {
|
|
id: device.id,
|
|
name: device.name,
|
|
location: device.location_description,
|
|
last_heartbeat: device.last_heartbeat,
|
|
time_offline_minutes: timeSinceLastHeartbeat
|
|
},
|
|
rule: {
|
|
id: rule.id,
|
|
name: rule.name
|
|
},
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
await alertService.sendWebhookAlert(rule.webhook_url, null, device, rule, webhookData);
|
|
console.log(`🔗 Device offline webhook alert sent for ${device.name}`);
|
|
}
|
|
break;
|
|
}
|
|
} catch (channelError) {
|
|
console.error(`❌ Failed to send ${channel} alert for offline device ${device.id}:`, channelError);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Error triggering offline alert for device ${device.id}:`, error);
|
|
}
|
|
}
|
|
|
|
async triggerDeviceRecoveryAlert(rule, device) {
|
|
try {
|
|
const AlertService = require('./alertService');
|
|
const alertService = new AlertService();
|
|
|
|
// Generate recovery alert message
|
|
const message = this.generateRecoveryMessage(device);
|
|
|
|
// Send recovery alerts through configured channels
|
|
const channels = rule.alert_channels || ['sms'];
|
|
|
|
for (const channel of channels) {
|
|
try {
|
|
switch (channel) {
|
|
case 'sms':
|
|
if (rule.sms_phone_number) {
|
|
await alertService.sendSMSAlert(rule.sms_phone_number, message, rule, null);
|
|
console.log(`📱 Device recovery SMS alert sent for ${device.name} to ${rule.sms_phone_number}`);
|
|
}
|
|
break;
|
|
|
|
case 'email':
|
|
if (rule.email || rule.user?.email) {
|
|
await alertService.sendEmailAlert(rule.email || rule.user.email, message, rule, null);
|
|
console.log(`📧 Device recovery email alert sent for ${device.name}`);
|
|
}
|
|
break;
|
|
|
|
case 'webhook':
|
|
if (rule.webhook_url) {
|
|
const webhookData = {
|
|
type: 'device_recovered',
|
|
device: {
|
|
id: device.id,
|
|
name: device.name,
|
|
location: device.location_description,
|
|
last_heartbeat: device.last_heartbeat
|
|
},
|
|
rule: {
|
|
id: rule.id,
|
|
name: rule.name
|
|
},
|
|
timestamp: new Date().toISOString()
|
|
};
|
|
|
|
await alertService.sendWebhookAlert(rule.webhook_url, null, device, rule, webhookData);
|
|
console.log(`🔗 Device recovery webhook alert sent for ${device.name}`);
|
|
}
|
|
break;
|
|
}
|
|
} catch (channelError) {
|
|
console.error(`❌ Failed to send ${channel} recovery alert for device ${device.id}:`, channelError);
|
|
}
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`❌ Error triggering recovery alert for device ${device.id}:`, error);
|
|
}
|
|
}
|
|
|
|
generateOfflineMessage(device, timeSinceLastHeartbeat) {
|
|
const deviceName = device.name || `Device ${device.id}`;
|
|
const location = device.location_description || 'Unknown location';
|
|
const lastSeen = device.last_heartbeat
|
|
? new Date(device.last_heartbeat).toLocaleString('sv-SE')
|
|
: 'Never';
|
|
|
|
return `🚨 DEVICE OFFLINE ALERT 🚨\n\n` +
|
|
`📍 LOCATION: ${location}\n` +
|
|
`🔧 DEVICE: ${deviceName}\n` +
|
|
`⏰ OFFLINE FOR: ${timeSinceLastHeartbeat} minutes\n` +
|
|
`📅 LAST SEEN: ${lastSeen}\n\n` +
|
|
`❌ Device has stopped sending heartbeats.\n` +
|
|
`🔧 Check device power, network connection, or physical access.\n\n` +
|
|
`⚠️ Security monitoring may be compromised in this area.`;
|
|
}
|
|
|
|
generateRecoveryMessage(device) {
|
|
const deviceName = device.name || `Device ${device.id}`;
|
|
const location = device.location_description || 'Unknown location';
|
|
const recoveredAt = new Date().toLocaleString('sv-SE');
|
|
|
|
return `✅ DEVICE RECOVERED ✅\n\n` +
|
|
`📍 LOCATION: ${location}\n` +
|
|
`🔧 DEVICE: ${deviceName}\n` +
|
|
`⏰ RECOVERED AT: ${recoveredAt}\n\n` +
|
|
`✅ Device is now sending heartbeats again.\n` +
|
|
`🛡️ Security monitoring restored for this area.`;
|
|
}
|
|
|
|
cleanupOfflineDevices(activeDevices) {
|
|
const activeDeviceIds = new Set(activeDevices.map(d => d.id));
|
|
|
|
for (const [deviceId] of this.offlineDevices) {
|
|
if (!activeDeviceIds.has(deviceId)) {
|
|
this.offlineDevices.delete(deviceId);
|
|
}
|
|
}
|
|
}
|
|
|
|
getStatus() {
|
|
return {
|
|
isRunning: this.isRunning,
|
|
checkInterval: this.checkInterval,
|
|
offlineThreshold: this.offlineThreshold,
|
|
offlineDevicesCount: this.offlineDevices.size,
|
|
offlineDevices: Array.from(this.offlineDevices.entries()).map(([id, data]) => ({
|
|
deviceId: id,
|
|
deviceName: data.device.name,
|
|
offlineSince: data.offlineSince,
|
|
alertSent: data.alertSent
|
|
}))
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = DeviceHealthService;
|