- src/utils/atomic-write.js: single shared tmp+fsync+rename writer (exclusive-create 0600, unique tmp names, cleanup-on-failure, best-effort parent-dir fsync after rename for swap durability) - notification-manager: both write paths (load-time canonicalization write-back + saveConfig) converted from plain writeFileSync — a crash mid-write can no longer truncate notifications.json - DC-097/098 test seams migrated to the atomic path; new suite pins syscall discipline (order, wx flags, tmp naming, error cleanup, dir-fsync swallow) - 121 suites / 2768 tests green - Judge: GLM-5.3 cold read grade B/ship (deleg_23f7abad); polish items folded: dir-fsync added, header copy-count corrected. Remaining: migrate invite/user/share-store private _atomicWriteJSON copies as they are touched (queued). URN: pending (recorded post-commit)
656 lines
22 KiB
JavaScript
656 lines
22 KiB
JavaScript
/**
|
|
* 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 { atomicWriteJSON } = require('../utils/atomic-write');
|
|
|
|
// Canonical event names are kebab-case ('container-down'). Emitters and the
|
|
// settings UI historically send camelCase ('containerDown', 'deploymentSuccess')
|
|
// and the alias map below folds every known spelling onto the canonical key.
|
|
// DC-092: before this map, the events gate looked up the RAW event name, so
|
|
// 'deploymentSuccess' (routes/apps/deploy.js, routes/recipes/deploy.js) and
|
|
// 'auto-restart' (resource-monitor.js) were absent from DEFAULT events and
|
|
// silently dropped, and UI camelCase toggles never reached the kebab keys the
|
|
// gate reads — the toggles were cosmetic.
|
|
// DC-094: recipe emitters used 'recipeRemoved' (camelCase) — alias onto the
|
|
// canonical kebab key like every other spelling drift before it.
|
|
const EVENT_ALIASES = {
|
|
containerDown: 'container-down',
|
|
containerUp: 'container-up',
|
|
deploymentSuccess: 'deploy-success',
|
|
deploymentFailed: 'deploy-failed',
|
|
deploySuccess: 'deploy-success',
|
|
deployFailed: 'deploy-failed',
|
|
resourceAlert: 'alert',
|
|
updateAvailable: 'update-available',
|
|
backupComplete: 'backup-complete',
|
|
backupFailed: 'backup-failed',
|
|
autoRestart: 'auto-restart',
|
|
recipeRemoved: 'recipe-removed',
|
|
// DC-094: dependency-manager fires two spellings; one canonical toggle
|
|
// gates both (stored configs with either key fold onto it at load).
|
|
'dependency-restart-complete': 'dependency-restart',
|
|
'dependency-restart-failed': 'dependency-restart',
|
|
};
|
|
|
|
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,
|
|
// DC-092: emitters (apps/recipes deploy routes) fire these; they were
|
|
// missing from defaults entirely, so every deploy notification was
|
|
// silently dropped before this fix.
|
|
'deploy-success': true,
|
|
'deploy-failed': true,
|
|
'auto-restart': true,
|
|
// DC-094: seven more emitters were absent from DEFAULT events, so the
|
|
// send() gate (config.events[canonical] !== true) silently dropped every
|
|
// one of them: SSL expiry warnings, DNS propagation results, config
|
|
// drift alerts, dependency restart results, recipe removals, and
|
|
// workflow notify actions. All default ON — every one of these fires
|
|
// only when something actually happened (and workflow notify actions
|
|
// are explicitly authored by the operator, so an off-by-default gate
|
|
// would just re-create this same silent-death bug). Recipe DEPLOY
|
|
// notifications need no key: recipes/deploy.js emits the
|
|
// deploymentSuccess/deploymentFailed aliases → deploy-success/failed.
|
|
'ssl-cert-expiry': true,
|
|
'dns-propagation': true,
|
|
'drift-detected': true,
|
|
'dependency-restart': true,
|
|
'recipe-removed': true,
|
|
'workflow': 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 raw = fs.readFileSync(this.NOTIFICATIONS_FILE, 'utf8');
|
|
const data = JSON.parse(raw);
|
|
this._canonicalizeLegacyKeys(data);
|
|
this.config = this._mergeConfig(DEFAULT_CONFIG, data);
|
|
this._persistCanonicalForm(raw);
|
|
}
|
|
} catch (error) {
|
|
this.log.error('notification', error, null, { note: 'Failed to load config' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DC-097: _canonicalizeLegacyKeys only fixed the file in memory — the
|
|
* on-disk file kept its legacy spellings (email user/pass, camelCase
|
|
* event keys, string `secure`) until the next explicit UI save, so any
|
|
* pre-DC-092 file stayed stale forever on installs that never touch the
|
|
* settings page. After the defaults merge, persist the canonical form
|
|
* whenever it differs from what is on disk. Best-effort: the config is
|
|
* already correct in memory, so a write failure (read-only mount, EACCES)
|
|
* must never block startup — warn and continue. Idempotent: once written,
|
|
* the re-serialized form matches the file byte-for-byte and no further
|
|
* writes happen on subsequent loads.
|
|
*/
|
|
_persistCanonicalForm(rawFileContents) {
|
|
try {
|
|
const canonical = JSON.stringify(this.config, null, 2);
|
|
if (canonical !== rawFileContents) {
|
|
// DC-099: tmp+fsync+rename — a crash mid-write can no longer leave a
|
|
// truncated/empty notifications.json (plain writeFileSync could).
|
|
atomicWriteJSON(this.NOTIFICATIONS_FILE, this.config);
|
|
this.log.info?.('notification', 'Notification config canonicalized on disk (legacy keys normalized)', {});
|
|
}
|
|
} catch (writeError) {
|
|
this.log.warn?.('notification', 'Failed to persist canonicalized notification config; continuing with in-memory config', { error: writeError?.message || String(writeError) });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* DC-092: configs saved by older clients may contain the legacy spellings
|
|
* the old POST /config merged verbatim — email.user/email.pass instead of
|
|
* username/password, and camelCase event keys instead of kebab-case. Fold
|
|
* them onto the canonical keys BEFORE the defaults merge (after the merge
|
|
* the canonical keys always exist from defaults, so the alias guards would
|
|
* never fire) so a config file written before this fix keeps working: SMTP
|
|
* auth applies and event toggles gate correctly.
|
|
*/
|
|
_canonicalizeLegacyKeys(data) {
|
|
// Email credentials: user/pass → username/password (only when the
|
|
// canonical key is absent in the raw data; canonical wins on conflict).
|
|
const email = data?.providers?.email;
|
|
if (email && typeof email === 'object') {
|
|
if (email.user !== undefined && email.username === undefined) email.username = email.user;
|
|
if (email.pass !== undefined && email.password === undefined) email.password = email.pass;
|
|
delete email.user;
|
|
delete email.pass;
|
|
// secure must be a real boolean: legacy string values (e.g. "false"
|
|
// from hand-edited JSON) are truthy under !! and would force TLS.
|
|
if (email.secure !== undefined) email.secure = email.secure === true;
|
|
}
|
|
// Event keys: camelCase → kebab-case canonical.
|
|
if (data?.events && typeof data.events === 'object') {
|
|
for (const [k, v] of Object.entries(data.events)) {
|
|
const canonicalKey = EVENT_ALIASES[k];
|
|
if (canonicalKey) {
|
|
if (data.events[canonicalKey] === undefined) data.events[canonicalKey] = v;
|
|
delete data.events[k];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 });
|
|
}
|
|
// DC-099: atomic tmp+fsync+rename — the UI save path gets the same
|
|
// crash-safety as the load-path write-back (no torn notifications.json).
|
|
atomicWriteJSON(this.NOTIFICATIONS_FILE, this.config);
|
|
return true;
|
|
} catch (error) {
|
|
this.log.error('notification', error, null, { note: 'Failed to save config' });
|
|
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') {
|
|
// DC-094: nine in-repo call sites used a legacy 4-arg shape
|
|
// send(event, title, message, type) against this 3-arg signature, so the
|
|
// message string silently landed in the `type` slot (Discord embed color
|
|
// fell back to info-blue) and providers received the TITLE as the body —
|
|
// deploy-failure notifications lost the actual error text entirely. The
|
|
// in-repo sites are fixed at source; this shim stays so any external
|
|
// caller of the same legacy shape keeps working instead of silently
|
|
// degrading again. Guarded on typeof data === 'string': the legacy shape
|
|
// always passed a string title as arg 2, so a hypothetical
|
|
// send(event, {...}, type, extra) call is left untouched rather than
|
|
// mangled by the rewrite.
|
|
if (arguments.length >= 4 && typeof data === 'string') {
|
|
const legacyTitle = data;
|
|
const legacyMessage = type;
|
|
const legacyType = arguments[3];
|
|
data = { title: legacyTitle, text: legacyMessage };
|
|
type = legacyType || 'info';
|
|
}
|
|
|
|
if (!this.config.enabled) {
|
|
return { success: false, error: 'Notifications disabled' };
|
|
}
|
|
|
|
// Fold legacy/camelCase spellings onto canonical kebab-case keys (DC-092).
|
|
const canonical = EVENT_ALIASES[event] || event;
|
|
|
|
// Check if event is enabled. 'test' bypasses the gate: it is the settings
|
|
// UI "Send Test" flow and is not an operator-togglable event (there is no
|
|
// 'test' key in events; gating on it made the Test button a no-op).
|
|
const gated = canonical !== 'test';
|
|
if (gated && this.config.events && this.config.events[canonical] !== true) {
|
|
return { success: false, error: `Event ${canonical} not enabled` };
|
|
}
|
|
|
|
// Provider-facing title: an explicit data.title (legacy 4-arg callers
|
|
// passed a specific one, e.g. "Recipe Deployed") beats the generic
|
|
// per-event title.
|
|
const title = (data && typeof data === 'object' && typeof data.title === 'string' && data.title)
|
|
|| this._formatTitle(canonical);
|
|
|
|
const results = [];
|
|
const providers = this.config.providers;
|
|
|
|
// Discord
|
|
if (providers.discord?.enabled && providers.discord?.webhookUrl) {
|
|
try {
|
|
const result = await this.sendDiscord(this._formatText(data, canonical), this._formatEmbed(data, canonical, 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, canonical));
|
|
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, canonical), title);
|
|
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(
|
|
title,
|
|
this._formatText(data, canonical)
|
|
);
|
|
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,
|
|
type,
|
|
event: canonical,
|
|
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 === true,
|
|
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',
|
|
// DC-094: newly gated events get real provider titles too.
|
|
'ssl-cert-expiry': 'SSL Certificate Expiry',
|
|
'dns-propagation': 'DNS Propagation',
|
|
'drift-detected': 'Configuration Drift',
|
|
'dependency-restart': 'Dependency Restart',
|
|
'recipe-removed': 'Recipe Removed',
|
|
'workflow': 'Workflow Notification'
|
|
};
|
|
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: (typeof data.title === 'string' && data.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', err, null, { note: 'Health check failed' });
|
|
});
|
|
}, 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', error, null, { note: 'Health check error' });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
getHealthState() {
|
|
return new Map(this.healthState);
|
|
}
|
|
}
|
|
|
|
module.exports = NotificationManager; |