const nodemailer = require('nodemailer');
const axios = require('axios');
const path = require('path');
const fs = require('fs');
require('dotenv').config();
class NotificationService {
constructor() {
this.applyConfigFromEnv();
}
applyConfigFromEnv() {
this.setConfig({
email_enabled: process.env.EMAIL_ENABLED === 'true',
email_host: process.env.EMAIL_HOST,
email_port: parseInt(process.env.EMAIL_PORT),
email_user: process.env.EMAIL_USER,
email_pass: process.env.EMAIL_PASS,
email_from: process.env.EMAIL_FROM,
email_to: process.env.EMAIL_TO,
webhook_enabled: process.env.WEBHOOK_ENABLED === 'true',
webhook_url: process.env.WEBHOOK_URL,
webhook_template: process.env.WEBHOOK_TEMPLATE || '',
ntfy_enabled: process.env.NTFY_ENABLED === 'true',
ntfy_url: process.env.NTFY_URL || 'https://ntfy.sh',
ntfy_topic: process.env.NTFY_TOPIC || '',
});
}
setConfig(config) {
this.emailEnabled = !!config.email_enabled;
this.webhookEnabled = !!config.webhook_enabled;
this.ntfyEnabled = !!config.ntfy_enabled;
this.config = { ...this.config, ...config };
// Recreate transporter when email config changes; skip if incomplete
if (
this.emailEnabled &&
config.email_host &&
config.email_port &&
config.email_user &&
config.email_pass
) {
this.transporter = nodemailer.createTransport({
host: config.email_host,
port: parseInt(config.email_port),
secure: false,
auth: {
user: config.email_user,
pass: config.email_pass,
},
connectionTimeout: 10000,
socketTimeout: 10000,
});
} else {
this.transporter = null;
}
}
async sendAlert(resource, incident, stats = null) {
// Check if incident is acknowledged
if (incident.acknowledged) {
return;
}
// Check maintenance windows
if (this.isInMaintenanceWindow(resource.id)) {
return;
}
// Check quiet hours
if (this.isQuietHours(resource)) {
return;
}
const isDown = incident.type === 'started';
const isSlow = incident.type === 'slow';
const statusEmoji = isDown ? '🔴' : isSlow ? '🟡' : '🟢';
const statusText = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
const slowDetail = isSlow ? `\nResponse Time: ${incident.responseTime}ms (threshold: ${incident.threshold}ms)` : '';
const message = `${statusEmoji} ${resource.name} is ${statusText}!\n\nURL: ${resource.url}\nCheck Type: ${resource.type || 'http'}\nTime: ${new Date().toLocaleString()}${slowDetail}`;
const promises = [];
// Use resource-specific email if provided, otherwise fall back to global config
if (this.emailEnabled && (resource.email_to || this.config.email_to)) {
const targetsCsv = resource.email_to || this.config.email_to;
const targets = String(targetsCsv)
.split(',')
.map(t => t.trim())
.filter(t => t && /.+@.+\..+/.test(t));
if (targets.length > 0) {
for (const email of targets) {
promises.push(this.sendEmail(resource, message, incident.type, stats, email));
}
}
} else {
if (!this.emailEnabled) {
// Email globally disabled
} else {
// No email target configured
}
}
if (this.webhookEnabled) {
promises.push(this.sendWebhook(resource, message, incident.type));
}
if (this.ntfyEnabled) {
promises.push(this.sendNtfy(resource, incident.type));
}
await Promise.allSettled(promises);
// Save notification to in-app notification center
const status = isDown ? 'down' : isSlow ? 'slow' : 'up';
this.saveNotification(resource, incident, status, message);
}
saveNotification(resource, incident, status, message) {
try {
const db = require('./database');
const title = status === 'down'
? `🔴 ${resource.name} is DOWN`
: status === 'slow'
? `🟡 ${resource.name} is SLOW`
: `🟢 ${resource.name} is UP`;
const result = db.prepare(`
INSERT INTO notifications (resource_id, incident_id, type, title, message, read)
VALUES (?, ?, ?, ?, ?, 0)
`).run(
resource.id,
incident.id || null,
status,
title,
message
);
// Broadcast new notification to WebSocket clients in real-time
if (global.broadcastNotification) {
global.broadcastNotification({
id: result.lastInsertRowid,
resource_id: resource.id,
resource_name: resource.name,
incident_id: incident.id || null,
type: status,
title,
message,
read: 0,
created_at: new Date().toISOString()
});
}
} catch (error) {
console.error('Failed to save notification:', error.message);
}
}
async flushAlerts(pendingAlerts) {
if (pendingAlerts.length === 0) return;
if (pendingAlerts.length === 1) {
const { resource, incident, stats } = pendingAlerts[0];
await this.sendAlert(resource, incident, stats);
return;
}
// Multiple alerts in one check cycle — save individual in-app entries but send one grouped email/webhook
for (const { resource, incident } of pendingAlerts) {
const status = incident.type === 'started' ? 'down' : 'up';
const message = `${resource.name} is ${status.toUpperCase()}. URL: ${resource.url}`;
this.saveNotification(resource, incident, status, message);
}
await this.sendGroupedEmail(pendingAlerts);
if (this.webhookEnabled) {
await this.sendGroupedWebhook(pendingAlerts);
}
if (this.ntfyEnabled) {
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
const ntfyTopic = this.config.ntfy_topic;
if (ntfyTopic) {
try {
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/`, {
topic: ntfyTopic,
title: `⚠️ ${pendingAlerts.length} monitors changed status`,
message: pendingAlerts.map(({ resource, incident }) =>
`${incident.type === 'started' ? '🔴' : '🟢'} ${resource.name}`
).join('\n'),
priority: 4,
tags: ['warning'],
});
} catch (_) {}
}
}
}
async sendGroupedEmail(alerts) {
if (!this.transporter) return;
const targetEmail = this.config.email_to;
if (!targetEmail || !this.emailEnabled) return;
try {
const rows = alerts.map(({ resource, incident }) => {
const status = incident.type === 'started' ? 'DOWN' : 'UP';
const emoji = incident.type === 'started' ? '🔴' : '🟢';
return `
${emoji} ${resource.name} is ${status} — ${resource.url}`;
}).join('');
await this.transporter.sendMail({
from: this.config.email_from,
to: targetEmail,
subject: `⚠️ ${alerts.length} monitors changed status`,
text: alerts.map(({ resource, incident }) =>
`${resource.name} is ${incident.type === 'started' ? 'DOWN' : 'UP'} — ${resource.url}`
).join('\n'),
html: `
⚠️ ${alerts.length} Monitors Changed Status
Time: ${new Date().toLocaleString()}
`,
});
} catch (error) {
// Grouped email error handled
}
}
async sendGroupedWebhook(alerts) {
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
if (!webhookUrl) return;
try {
await axios.post(webhookUrl, {
type: 'grouped_alert',
count: alerts.length,
resources: alerts.map(({ resource, incident }) => ({
name: resource.name,
url: resource.url,
status: incident.type === 'started' ? 'down' : 'up',
})),
timestamp: new Date().toISOString(),
});
} catch (error) {
// Grouped webhook error handled
}
}
async sendEscalationAlert(incident, fallbackWebhookUrl) {
try {
const durationMin = Math.round((Date.now() - new Date(incident.started_at).getTime()) / 60000);
const message = `⚠️ ESCALATION: ${incident.resource_name} has been DOWN for ${durationMin} minutes without resolution.`;
if (fallbackWebhookUrl) {
await axios.post(fallbackWebhookUrl, {
resource: incident.resource_name,
url: incident.resource_url,
status: 'escalated',
duration_minutes: durationMin,
incident_id: incident.id,
started_at: incident.started_at,
message,
timestamp: new Date().toISOString(),
});
}
this.saveNotification(
{ id: incident.resource_id, name: incident.resource_name, url: incident.resource_url },
{ id: incident.id },
'escalated',
message
);
} catch (error) {
// Escalation alert error handled
}
}
isQuietHours(resource) {
if (!resource.quiet_hours_start || !resource.quiet_hours_end) {
return false;
}
const now = new Date();
const currentTime = now.getHours() * 60 + now.getMinutes();
const [startHour, startMin] = resource.quiet_hours_start.split(':').map(Number);
const [endHour, endMin] = resource.quiet_hours_end.split(':').map(Number);
const startTime = startHour * 60 + startMin;
const endTime = endHour * 60 + endMin;
// Handle overnight quiet hours
if (startTime > endTime) {
return currentTime >= startTime || currentTime < endTime;
}
return currentTime >= startTime && currentTime < endTime;
}
async sendEmail(resource, message, type, stats = null, emailOverride = null) {
if (!this.transporter) {
return;
}
try {
const logoPath = path.join(__dirname, '..', 'client', 'public', 'app-icon.png');
const hasLogo = fs.existsSync(logoPath);
const logoCid = 'skywatch-app-icon';
const logoBlock = hasLogo
? ``
: '';
const isSlow = type === 'slow';
const isDown = type === 'started';
const headingColor = isDown ? '#d32f2f' : isSlow ? '#f57c00' : '#388e3c';
const headingText = isDown ? '🔴 Alert' : isSlow ? '🟡 Slow Response' : '🟢 Recovered';
const statusLabel = isDown ? 'DOWN' : isSlow ? 'SLOW' : 'UP';
let htmlContent = `
${logoBlock}
${headingText}
Resource: ${resource.name}
Status: ${statusLabel}
URL: ${resource.url}
Check Type: ${resource.type || 'http'}
Time: ${new Date().toLocaleString()}
`;
if (stats) {
const uptimePct = Number(stats.uptime);
const avgMs = Number(stats.avgResponseTime);
htmlContent += `
Last 24 Hours Performance
Uptime: ${Number.isFinite(uptimePct) ? (uptimePct * 100).toFixed(2) : 'N/A'}%
Avg Response Time: ${Number.isFinite(avgMs) ? avgMs.toFixed(0) : 'N/A'}ms
Last Check: ${stats.lastCheck || 'Never'}
`;
if (stats.recentChecks && stats.recentChecks.length > 0) {
const graph = this.generateAsciiGraph(stats.recentChecks);
htmlContent += `
Recent Response Times (last 12 checks):
${graph}
`;
}
htmlContent += `
`;
}
htmlContent += `
`;
// Use resource-specific email if provided, otherwise use global config
const emailTo = emailOverride || resource.email_to || this.config.email_to;
await this.transporter.sendMail({
from: this.config.email_from,
to: emailTo,
subject: `Alert: ${resource.name} is ${type === 'started' ? 'DOWN' : type === 'slow' ? 'SLOW' : 'UP'}`,
text: message,
html: htmlContent,
attachments: hasLogo
? [{ filename: 'app-icon.png', path: logoPath, cid: logoCid }]
: [],
});
} catch (error) {
// Email error handled
}
}
generateAsciiGraph(recentChecks) {
if (!recentChecks || recentChecks.length === 0) return '';
// Get last 12 checks and their response times
const checks = recentChecks.slice(-12);
const times = checks.map(c => c.response_time || 0);
const maxTime = Math.max(...times);
if (maxTime === 0) return 'No data';
// Simple ASCII bar chart (8 rows)
const height = 8;
let graph = '';
for (let row = height; row > 0; row--) {
const threshold = (maxTime / height) * row;
for (const time of times) {
graph += time >= threshold ? '█' : ' ';
}
graph += '\n';
}
// Add baseline
graph += times.map(() => '─').join('') + '\n';
// Add labels
graph += `0ms${' '.repeat(Math.max(0, times.length - 5))}${(maxTime).toFixed(0)}ms`;
return graph;
}
isInMaintenanceWindow(resourceId) {
try {
const db = require('./database');
const now = new Date().toISOString().split('.')[0];
const window = db.prepare(`
SELECT id FROM maintenance_windows
WHERE resource_id = ? AND start_time <= ? AND end_time > ?
LIMIT 1
`).get(resourceId, now, now);
return !!window;
} catch (error) {
return false;
}
}
async sendWebhook(resource, message, type) {
try {
const webhookUrl = this.config.webhook_url || process.env.WEBHOOK_URL;
const status = type === 'started' ? 'down' : type === 'slow' ? 'slow' : 'up';
const template = this.config.webhook_template;
let payload;
if (template) {
const interpolated = template
.replace(/\{\{name\}\}/g, resource.name)
.replace(/\{\{status\}\}/g, status)
.replace(/\{\{url\}\}/g, resource.url)
.replace(/\{\{type\}\}/g, resource.type || 'http')
.replace(/\{\{message\}\}/g, message)
.replace(/\{\{timestamp\}\}/g, new Date().toISOString());
try {
payload = JSON.parse(interpolated);
} catch {
// Template is plain text (e.g. Slack text field), wrap it
payload = { text: interpolated };
}
} else {
payload = {
resource: resource.name,
url: resource.url,
status,
message,
timestamp: new Date().toISOString(),
};
}
await axios.post(webhookUrl, payload);
} catch (error) {
// Webhook error handled silently
}
}
async sendAgentAlert(agent, type, message) {
const isOffline = type === 'offline';
const isOnline = type === 'online';
const emoji = isOffline ? '🔴' : isOnline ? '🟢' : '⚠️';
const title = isOffline ? `Agent Offline: ${agent.name}`
: isOnline ? `Agent Online: ${agent.name}`
: `Agent Alert: ${agent.name}`;
const notifType = isOffline ? 'down' : isOnline ? 'up' : 'warning';
// In-app notification
try {
const db = require('./database');
const result = db.prepare(`
INSERT INTO notifications (resource_id, incident_id, type, title, message)
VALUES (NULL, NULL, ?, ?, ?)
`).run(notifType, title, message);
if (global.broadcastNotification) {
global.broadcastNotification({
id: result.lastInsertRowid, resource_id: null, resource_name: agent.name,
incident_id: null, type: notifType, title, message, read: 0,
created_at: new Date().toISOString(),
});
}
} catch (_) {}
// Email
if (this.emailEnabled && this.transporter && this.config.email_to) {
try {
await this.transporter.sendMail({
from: this.config.email_from,
to: this.config.email_to,
subject: `${emoji} SkyWatch Agent: ${title}`,
text: [
message,
'',
`Agent: ${agent.name}`,
`Hostname: ${agent.hostname || '—'}`,
`IP: ${agent.ip_address || '—'}`,
`OS: ${agent.os_info || '—'}`,
`Time: ${new Date().toLocaleString()}`,
].join('\n'),
});
} catch (_) {}
}
// Webhook
if (this.webhookEnabled && this.config.webhook_url) {
try {
await axios.post(this.config.webhook_url, {
type: 'agent', event: type,
agent: { id: agent.id, name: agent.name, hostname: agent.hostname, ip_address: agent.ip_address },
title, message, timestamp: new Date().toISOString(),
});
} catch (_) {}
}
// Ntfy
if (this.ntfyEnabled && this.config.ntfy_topic) {
const ntfyBase = (this.config.ntfy_url || 'https://ntfy.sh').replace(/\/$/, '');
try {
await axios.post(`${ntfyBase}/`, {
topic: this.config.ntfy_topic,
title: `${emoji} ${title}`,
message,
priority: isOffline ? 4 : isOnline ? 2 : 3,
tags: isOffline ? ['red_circle'] : isOnline ? ['green_circle'] : ['warning'],
});
} catch (_) {}
}
}
async sendNtfy(resource, type) {
const ntfyUrl = this.config.ntfy_url || 'https://ntfy.sh';
const ntfyTopic = this.config.ntfy_topic;
if (!ntfyTopic) return;
const isDown = type === 'started';
const isSlow = type === 'slow';
const title = isDown ? `🔴 ${resource.name} is DOWN` : isSlow ? `🟡 ${resource.name} is SLOW` : `🟢 ${resource.name} is UP`;
const priority = isDown ? 4 : isSlow ? 3 : 2;
const tags = isDown ? ['red_circle', 'warning'] : isSlow ? ['yellow_circle'] : ['green_circle', 'white_check_mark'];
try {
await axios.post(`${ntfyUrl.replace(/\/$/, '')}/`, {
topic: ntfyTopic,
title,
message: resource.url || resource.name,
priority,
tags,
});
} catch (error) {
// ntfy error handled silently
}
}
}
module.exports = new NotificationService();