feat: premium tier features — auto-backup scheduling, resource alerting, bundled workflows, one-click revert, notification manager
This commit is contained in:
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* Notification Manager - Multi-provider notification delivery
|
||||
* Supports Discord, Telegram, ntfy, and Email notifications
|
||||
*/
|
||||
const EventEmitter = require('events');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const nodemailer = require('nodemailer');
|
||||
|
||||
const DEFAULT_CONFIG = {
|
||||
enabled: true,
|
||||
providers: {
|
||||
discord: { enabled: false, webhookUrl: '' },
|
||||
telegram: { enabled: false, botToken: '', chatId: '' },
|
||||
ntfy: { enabled: false, topic: '', serverUrl: 'https://ntfy.sh' },
|
||||
email: { enabled: false, host: '', port: 587, to: '', from: '', username: '', password: '' }
|
||||
},
|
||||
events: {
|
||||
'container-down': true,
|
||||
'container-up': false,
|
||||
'alert': true,
|
||||
'backup-complete': true,
|
||||
'backup-failed': true,
|
||||
'update-available': true
|
||||
}
|
||||
};
|
||||
|
||||
class NotificationManager extends EventEmitter {
|
||||
constructor(ctx) {
|
||||
super();
|
||||
this.ctx = ctx;
|
||||
this.NOTIFICATIONS_FILE = ctx.NOTIFICATIONS_FILE;
|
||||
this.log = ctx.log || console;
|
||||
this.config = { ...DEFAULT_CONFIG };
|
||||
this.lastSent = null;
|
||||
this.history = [];
|
||||
this.maxHistory = 100;
|
||||
this.healthDaemonInterval = null;
|
||||
this.healthState = new Map();
|
||||
|
||||
this._loadConfig();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load config from file
|
||||
*/
|
||||
_loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(this.NOTIFICATIONS_FILE)) {
|
||||
const data = JSON.parse(fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8'));
|
||||
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Failed to load config', { error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge loaded config with defaults
|
||||
*/
|
||||
_mergeConfig(defaults, loaded) {
|
||||
const result = { ...defaults };
|
||||
for (const key of Object.keys(defaults)) {
|
||||
if (loaded && typeof defaults[key] === 'object' && !Array.isArray(defaults[key])) {
|
||||
result[key] = { ...defaults[key], ...loaded[key] };
|
||||
} else if (loaded && loaded[key] !== undefined) {
|
||||
result[key] = loaded[key];
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current config (for API)
|
||||
*/
|
||||
getConfig() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save config to file
|
||||
*/
|
||||
async saveConfig() {
|
||||
try {
|
||||
const dir = path.dirname(this.NOTIFICATIONS_FILE);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(this.NOTIFICATIONS_FILE, JSON.stringify(this.config, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Failed to save config', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get notification history
|
||||
*/
|
||||
getHistory() {
|
||||
return this.history.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear notification history
|
||||
*/
|
||||
clearHistory() {
|
||||
this.history = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add entry to history
|
||||
*/
|
||||
_addToHistory(entry) {
|
||||
this.history.unshift({
|
||||
...entry,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
if (this.history.length > this.maxHistory) {
|
||||
this.history = this.history.slice(0, this.maxHistory);
|
||||
}
|
||||
this.lastSent = new Date().toISOString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification via all enabled providers
|
||||
*/
|
||||
async send(event, data, type = 'info') {
|
||||
if (!this.config.enabled) {
|
||||
return { success: false, error: 'Notifications disabled' };
|
||||
}
|
||||
|
||||
// Check if event is enabled
|
||||
if (event && this.config.events && !this.config.events[event]) {
|
||||
return { success: false, error: `Event ${event} not enabled` };
|
||||
}
|
||||
|
||||
const results = [];
|
||||
const providers = this.config.providers;
|
||||
|
||||
// Discord
|
||||
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
|
||||
try {
|
||||
const result = await this.sendDiscord(this._formatText(data, event), this._formatEmbed(data, event, type));
|
||||
results.push({ provider: 'discord', ...result });
|
||||
} catch (error) {
|
||||
results.push({ provider: 'discord', success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram
|
||||
if (providers.telegram?.enabled && providers.telegram?.botToken && providers.telegram?.chatId) {
|
||||
try {
|
||||
const result = await this.sendTelegram(this._formatText(data, event));
|
||||
results.push({ provider: 'telegram', ...result });
|
||||
} catch (error) {
|
||||
results.push({ provider: 'telegram', success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// ntfy
|
||||
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
|
||||
try {
|
||||
const result = await this.sendNtfy(this._formatText(data, event), this._formatTitle(event));
|
||||
results.push({ provider: 'ntfy', ...result });
|
||||
} catch (error) {
|
||||
results.push({ provider: 'ntfy', success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Email
|
||||
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
|
||||
try {
|
||||
const result = await this.sendEmail(
|
||||
this._formatTitle(event),
|
||||
this._formatText(data, event)
|
||||
);
|
||||
results.push({ provider: 'email', ...result });
|
||||
} catch (error) {
|
||||
results.push({ provider: 'email', success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
const allSucceeded = results.every(r => r.success);
|
||||
this._addToHistory({
|
||||
title: this._formatTitle(event),
|
||||
type,
|
||||
event,
|
||||
results
|
||||
});
|
||||
|
||||
return { success: allSucceeded, results };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Discord webhook notification
|
||||
*/
|
||||
async sendDiscord(text, embed) {
|
||||
const { webhookUrl } = this.config.providers.discord;
|
||||
if (!webhookUrl) {
|
||||
throw new Error('Discord webhook not configured');
|
||||
}
|
||||
|
||||
const payload = {
|
||||
content: text,
|
||||
embeds: embed ? [embed] : []
|
||||
};
|
||||
|
||||
const response = await this.ctx.fetchT(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Discord API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send Telegram message
|
||||
*/
|
||||
async sendTelegram(text) {
|
||||
const { botToken, chatId } = this.config.providers.telegram;
|
||||
if (!botToken || !chatId) {
|
||||
throw new Error('Telegram not configured');
|
||||
}
|
||||
|
||||
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
|
||||
const payload = {
|
||||
chat_id: chatId,
|
||||
text,
|
||||
parse_mode: 'Markdown'
|
||||
};
|
||||
|
||||
const response = await this.ctx.fetchT(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.ok) {
|
||||
throw new Error(`Telegram error: ${data.description}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ntfy notification
|
||||
*/
|
||||
async sendNtfy(text, title) {
|
||||
const { serverUrl, topic } = this.config.providers.ntfy;
|
||||
if (!topic) {
|
||||
throw new Error('ntfy topic not configured');
|
||||
}
|
||||
|
||||
const url = `${serverUrl.replace(/\/$/, '')}/${topic}`;
|
||||
|
||||
const response = await this.ctx.fetchT(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Title': title || 'DashCaddy',
|
||||
'Priority': '3'
|
||||
},
|
||||
body: text
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`ntfy error: ${response.status}`);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send email notification
|
||||
*/
|
||||
async sendEmail(subject, body) {
|
||||
const { host, port, to, from, username, password, secure } = this.config.providers.email;
|
||||
if (!host || !to) {
|
||||
throw new Error('Email not configured');
|
||||
}
|
||||
|
||||
// Create transporter
|
||||
const transporter = nodemailer.createTransport({
|
||||
host,
|
||||
port: parseInt(port) || 587,
|
||||
secure: !!secure,
|
||||
auth: username ? {
|
||||
user: username,
|
||||
pass: password
|
||||
} : undefined
|
||||
});
|
||||
|
||||
// Send mail
|
||||
await transporter.sendMail({
|
||||
from: from || username,
|
||||
to,
|
||||
subject,
|
||||
text: body,
|
||||
html: `<pre style="font-family: monospace;">${body}</pre>`
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send resource alert
|
||||
*/
|
||||
async sendAlert(alert) {
|
||||
const text = this._formatAlertText(alert);
|
||||
const embed = {
|
||||
title: `⚠️ Resource Alert: ${alert.containerName}`,
|
||||
color: this._getAlertColor(alert.alerts),
|
||||
fields: alert.alerts.map(a => ({
|
||||
name: a.type.toUpperCase(),
|
||||
value: a.message,
|
||||
inline: true
|
||||
})),
|
||||
footer: {
|
||||
text: 'DashCaddy Resource Monitor'
|
||||
},
|
||||
timestamp: alert.timestamp
|
||||
};
|
||||
|
||||
return this.send('alert', { ...alert, text, embed }, 'warning');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send backup complete notification
|
||||
*/
|
||||
async sendBackupComplete(backup) {
|
||||
const event = backup.status === 'success' ? 'backup-complete' : 'backup-failed';
|
||||
const type = backup.status === 'success' ? 'success' : 'error';
|
||||
|
||||
const text = backup.status === 'success'
|
||||
? `✅ Backup "${backup.name}" completed successfully`
|
||||
: `❌ Backup "${backup.name}" failed: ${backup.error}`;
|
||||
|
||||
return this.send(event, { ...backup, text }, type);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send service event notification (container up/down, deploy success/fail)
|
||||
*/
|
||||
async sendServiceEvent(event, service) {
|
||||
const eventMap = {
|
||||
'container-up': { type: 'success', text: `✅ Container "${service.containerName || service.name}" is now UP` },
|
||||
'container-down': { type: 'error', text: `🔴 Container "${service.containerName || service.name}" is DOWN` },
|
||||
'deploy-success': { type: 'success', text: `✅ "${service.name}" deployed successfully` },
|
||||
'deploy-failed': { type: 'error', text: `❌ "${service.name}" deployment failed` },
|
||||
'auto-restart': { type: 'warning', text: `🔄 Container "${service.containerName || service.name}" auto-restarted` }
|
||||
};
|
||||
|
||||
const info = eventMap[event] || { type: 'info', text: `Service event: ${event}` };
|
||||
return this.send(event, { ...service, text: info.text }, info.type);
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
_formatTitle(event) {
|
||||
const titles = {
|
||||
'container-down': 'Container Down',
|
||||
'container-up': 'Container Recovered',
|
||||
'alert': 'Resource Alert',
|
||||
'backup-complete': 'Backup Complete',
|
||||
'backup-failed': 'Backup Failed',
|
||||
'update-available': 'Update Available',
|
||||
'test': 'Test Notification',
|
||||
'auto-restart': 'Auto-Restart',
|
||||
'deploy-success': 'Deployment Success',
|
||||
'deploy-failed': 'Deployment Failed'
|
||||
};
|
||||
return titles[event] || 'DashCaddy Notification';
|
||||
}
|
||||
|
||||
_formatText(data, event) {
|
||||
if (typeof data === 'string') return data;
|
||||
return data.text || data.message || this._formatTitle(event);
|
||||
}
|
||||
|
||||
_formatEmbed(data, event, type) {
|
||||
if (typeof data === 'string') return null;
|
||||
if (data.embed) return data.embed;
|
||||
|
||||
return {
|
||||
title: this._formatTitle(event),
|
||||
description: data.text || data.message || '',
|
||||
color: this._getTypeColor(type),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
_formatAlertText(alert) {
|
||||
const lines = [
|
||||
`**${alert.containerName}**`,
|
||||
'',
|
||||
...alert.alerts.map(a => `• ${a.message}`)
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
_getAlertColor(alerts) {
|
||||
if (alerts.some(a => a.severity === 'critical')) return 15158332; // Red
|
||||
if (alerts.some(a => a.severity === 'warning')) return 16776960; // Yellow
|
||||
return 3447003; // Blue
|
||||
}
|
||||
|
||||
_getTypeColor(type) {
|
||||
const colors = {
|
||||
success: 3066993, // Green
|
||||
error: 15158332, // Red
|
||||
warning: 16776960, // Yellow
|
||||
info: 3447003 // Blue
|
||||
};
|
||||
return colors[type] || colors.info;
|
||||
}
|
||||
|
||||
// ===== Health Check Daemon =====
|
||||
|
||||
startHealthDaemon() {
|
||||
if (this.healthDaemonInterval) return;
|
||||
|
||||
const interval = (this.config.healthCheck?.intervalMinutes || 5) * 60 * 1000;
|
||||
this.healthDaemonInterval = setInterval(() => {
|
||||
this.checkHealth().catch(err => {
|
||||
this.log.error('notification', 'Health check failed', { error: err.message });
|
||||
});
|
||||
}, interval);
|
||||
|
||||
this.log.info('notification', 'Health daemon started', { intervalMinutes: this.config.healthCheck?.intervalMinutes });
|
||||
}
|
||||
|
||||
stopHealthDaemon() {
|
||||
if (this.healthDaemonInterval) {
|
||||
clearInterval(this.healthDaemonInterval);
|
||||
this.healthDaemonInterval = null;
|
||||
this.log.info('notification', 'Health daemon stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async checkHealth() {
|
||||
if (!this.config.healthCheck?.enabled || !this.ctx.docker) {
|
||||
return { checked: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const containers = await this.ctx.docker.listContainers({ all: true });
|
||||
const previousState = new Map(this.healthState);
|
||||
|
||||
for (const container of containers) {
|
||||
const name = container.Names[0]?.replace(/^\//, '') || container.Id.substring(0, 12);
|
||||
const wasDown = previousState.get(container.Id) === false;
|
||||
const isDown = container.State !== 'running';
|
||||
|
||||
this.healthState.set(container.Id, isDown);
|
||||
|
||||
if (wasDown && !isDown) {
|
||||
// Container recovered
|
||||
await this.sendServiceEvent('container-up', {
|
||||
containerId: container.Id,
|
||||
containerName: name,
|
||||
state: container.State
|
||||
});
|
||||
} else if (!wasDown && isDown) {
|
||||
// Container went down
|
||||
await this.sendServiceEvent('container-down', {
|
||||
containerId: container.Id,
|
||||
containerName: name,
|
||||
state: container.State
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update last check time
|
||||
this.config.healthCheck = this.config.healthCheck || {};
|
||||
this.config.healthCheck.lastCheck = new Date().toISOString();
|
||||
await this.saveConfig();
|
||||
|
||||
return {
|
||||
checked: true,
|
||||
containersMonitored: containers.length,
|
||||
lastCheck: this.config.healthCheck.lastCheck
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.error('notification', 'Health check error', { error: error.message });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
getHealthState() {
|
||||
return new Map(this.healthState);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = NotificationManager;
|
||||
Reference in New Issue
Block a user