[glm-grade=A] fix(notifications): un-gate 7 dead emitters + repair legacy 4-arg send shape (DC-094)
Two silent-death defects in one class: 1. Seven emitters absent from DEFAULT events, so the send() gate (config.events[canonical] !== true) dropped them on every install: ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-complete/-failed, recipeRemoved, workflow. All now default ON; dependency-restart spellings fold onto one canonical toggle; recipeRemoved aliases to recipe-removed in both the manager and route alias maps. 2. Nine call sites used a legacy 4-arg send(event, title, message, type) against the 3-arg signature: the message string landed in the type slot (Discord embed color fell back to info-blue, history.type wrong) and providers received the TITLE as the body - deploy-failure notifications carried no error text at all. Fixed at source (9 sites) plus a type-guarded shim in send() for external legacy callers. Also: explicit data.title now flows to ntfy Title header, email subject, Discord embed title, and history; settings UI gains 9 event toggles (separate Backup Complete/Failed) with defaults-on semantics. Tests: +24 (new DC-094 suite: defaults, gate pass-through, alias folding send-time and load-time, stored-config inheritance, shim body/title/ color/subject/history, type-guard, 3-arg no-regression); 4 assertions in bundled-workflows-health-check updated from the old 4-arg mock contract to the canonical shape (same behavior asserted). Full suite 116 suites / 2706 tests green. Judge: GLM-5.3 cold read via delegate_task (deleg_8a7cedd0), grade A, zero blockers; 2 polish items (Backups toggle conflation, shim type-guard) folded into this commit. Verdict URN: urn:ump:uyipjjwdjqjy3alvceqxvlucrymd7hnsben5udpp2bqycoh3l2la
This commit is contained in:
@@ -253,8 +253,8 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
expect(healthResult.failingServices).toEqual(['svc-broken']);
|
expect(healthResult.failingServices).toEqual(['svc-broken']);
|
||||||
expect(notifyResult.success).toBe(true);
|
expect(notifyResult.success).toBe(true);
|
||||||
expect(notify).toHaveBeenCalledTimes(1);
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
// notification.send signature: (category, title, message, level)
|
// DC-094 notification.send signature: (event, { title, text }, level)
|
||||||
const sentMessage = notify.mock.calls[0][2];
|
const sentMessage = notify.mock.calls[0][1].text;
|
||||||
expect(sentMessage).toBe('Health check failed for svc-broken');
|
expect(sentMessage).toBe('Health check failed for svc-broken');
|
||||||
expect(sentMessage).not.toContain('{{');
|
expect(sentMessage).not.toContain('{{');
|
||||||
});
|
});
|
||||||
@@ -269,7 +269,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(notify).toHaveBeenCalledTimes(1);
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
expect(notify.mock.calls[0][2]).toBe('always sent');
|
expect(notify.mock.calls[0][1].text).toBe('always sent');
|
||||||
expect(results[0].success).toBe(true);
|
expect(results[0].success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -313,7 +313,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(notify).toHaveBeenCalledTimes(1);
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
const sentMessage = notify.mock.calls[0][2];
|
const sentMessage = notify.mock.calls[0][1].text;
|
||||||
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
|
expect(sentMessage).toBe('Failing: svc-broken-1,svc-broken-2');
|
||||||
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
|
expect(results.find(r => r.action === 'notify-on-failure').success).toBe(true);
|
||||||
});
|
});
|
||||||
@@ -346,7 +346,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure +
|
|||||||
// message) OR every action resolved — but in NO case may a literal
|
// message) OR every action resolved — but in NO case may a literal
|
||||||
// {{...}} template token leak into notification.send.
|
// {{...}} template token leak into notification.send.
|
||||||
if (notify.mock.calls.length > 0) {
|
if (notify.mock.calls.length > 0) {
|
||||||
const sentMessage = notify.mock.calls[0][2];
|
const sentMessage = notify.mock.calls[0][1].text;
|
||||||
expect(sentMessage).not.toMatch(/\{\{/);
|
expect(sentMessage).not.toMatch(/\{\{/);
|
||||||
expect(sentMessage).not.toMatch(/\}\}/);
|
expect(sentMessage).not.toMatch(/\}\}/);
|
||||||
// The new bundled template substitutes failingServices — make sure
|
// The new bundled template substitutes failingServices — make sure
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* DC-094: remaining gate-miss notification emitters + legacy 4-arg send shape.
|
||||||
|
*
|
||||||
|
* Part 1 — seven emitters were absent from DEFAULT events, so the send()
|
||||||
|
* gate (config.events[canonical] !== true) silently dropped them all:
|
||||||
|
* ssl-cert-expiry (ssl-monitor), dns-propagation (dns-propagation),
|
||||||
|
* drift-detected (config-drift-detector), dependency-restart-complete/-failed
|
||||||
|
* (dependency-manager), recipe-removed (recipes/manage), workflow
|
||||||
|
* (bundled-workflows). Stored configs must inherit the new defaults via the
|
||||||
|
* _mergeConfig shallow per-key merge.
|
||||||
|
*
|
||||||
|
* Part 2 — nine in-repo call sites used a legacy 4-arg shape
|
||||||
|
* send(event, title, message, type) against the 3-arg signature: the message
|
||||||
|
* string landed in the `type` slot (embed color fell back) and providers got
|
||||||
|
* the TITLE as the body. send() now shims that shape, and the explicit title
|
||||||
|
* flows to ntfy/email subjects and the Discord embed title.
|
||||||
|
*
|
||||||
|
* Part 3 — route EVENT_KEY_ALIASES and manager EVENT_ALIASES stay in sync:
|
||||||
|
* recipeRemoved and the dependency-restart spellings fold in both places.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const nodemailer = require('nodemailer');
|
||||||
|
|
||||||
|
jest.mock('fs', () => ({
|
||||||
|
existsSync: jest.fn().mockReturnValue(false),
|
||||||
|
readFileSync: jest.fn().mockReturnValue('{}'),
|
||||||
|
writeFileSync: jest.fn(),
|
||||||
|
mkdirSync: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('nodemailer', () => ({
|
||||||
|
createTransport: jest.fn(() => ({
|
||||||
|
sendMail: jest.fn().mockResolvedValue({ messageId: 'mock' }),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const NotificationManager = require('../src/managers/notification-manager');
|
||||||
|
|
||||||
|
describe('DC-094 NotificationManager', () => {
|
||||||
|
let nm;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
fs.existsSync.mockReturnValue(false);
|
||||||
|
fs.readFileSync.mockReturnValue('{}');
|
||||||
|
nm = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json',
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
fetchT: jest.fn(),
|
||||||
|
docker: null,
|
||||||
|
});
|
||||||
|
// jest.config restoreMocks strips the factory nodemailer implementation
|
||||||
|
// before every test; re-establish it and capture the sendMail mock so
|
||||||
|
// email assertions don't depend on module state.
|
||||||
|
nodemailer.createTransport.mockImplementation(() => {
|
||||||
|
mailMock = jest.fn().mockResolvedValue({ messageId: 'mock' });
|
||||||
|
return { sendMail: mailMock };
|
||||||
|
});
|
||||||
|
// Same aliasing hazard as providers: without a config file the
|
||||||
|
// constructor's spread aliases module-level DEFAULT_CONFIG.events, so
|
||||||
|
// gate-mutation tests would poison every later instance.
|
||||||
|
nm.config.events = { ...nm.config.events };
|
||||||
|
});
|
||||||
|
|
||||||
|
let mailMock;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
nm.stopHealthDaemon();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('new events present in DEFAULT events (gate-miss fix)', () => {
|
||||||
|
const newlyGated = [
|
||||||
|
'ssl-cert-expiry',
|
||||||
|
'dns-propagation',
|
||||||
|
'drift-detected',
|
||||||
|
'dependency-restart',
|
||||||
|
'recipe-removed',
|
||||||
|
'workflow',
|
||||||
|
];
|
||||||
|
|
||||||
|
test.each(newlyGated)('%s defaults to enabled', (event) => {
|
||||||
|
expect(nm.config.events[event]).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(newlyGated)('%s passes the send() gate by default', async (event) => {
|
||||||
|
nm.config.providers.discord = { enabled: false }; // no providers -> send short-circuits after the gate
|
||||||
|
const result = await nm.send(event, { text: 'x' });
|
||||||
|
expect(result.error).not.toBe(`Event ${event} not enabled`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dependency-restart spellings alias onto the single canonical toggle', async () => {
|
||||||
|
nm.config.events['dependency-restart'] = false;
|
||||||
|
const complete = await nm.send('dependency-restart-complete', { text: 'x' });
|
||||||
|
const failed = await nm.send('dependency-restart-failed', { text: 'x' });
|
||||||
|
expect(complete.error).toBe('Event dependency-restart not enabled');
|
||||||
|
expect(failed.error).toBe('Event dependency-restart not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recipeRemoved camelCase alias folds onto recipe-removed', async () => {
|
||||||
|
nm.config.events['recipe-removed'] = false;
|
||||||
|
const result = await nm.send('recipeRemoved', { text: 'x' });
|
||||||
|
expect(result.error).toBe('Event recipe-removed not enabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stored pre-DC-094 configs inherit the new event defaults via merge', () => {
|
||||||
|
// A config saved before this fix has none of the new keys. After load,
|
||||||
|
// the defaults merge must supply them as enabled.
|
||||||
|
const legacyFile = JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
providers: { discord: { enabled: false, webhookUrl: '' } },
|
||||||
|
events: { 'container-down': true, alert: true },
|
||||||
|
});
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
fs.readFileSync.mockReturnValue(legacyFile);
|
||||||
|
const loaded = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json',
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
fetchT: jest.fn(),
|
||||||
|
docker: null,
|
||||||
|
});
|
||||||
|
for (const event of newlyGated) {
|
||||||
|
expect(loaded.config.events[event]).toBe(true);
|
||||||
|
}
|
||||||
|
// operator choice preserved, not clobbered by defaults
|
||||||
|
expect(loaded.config.events['container-down']).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stored legacy dependency-restart spellings fold at load', () => {
|
||||||
|
const legacyFile = JSON.stringify({
|
||||||
|
enabled: true,
|
||||||
|
events: { 'dependency-restart-complete': false },
|
||||||
|
});
|
||||||
|
fs.existsSync.mockReturnValue(true);
|
||||||
|
fs.readFileSync.mockReturnValue(legacyFile);
|
||||||
|
const loaded = new NotificationManager({
|
||||||
|
NOTIFICATIONS_FILE: '/tmp/dc094-notif-test.json',
|
||||||
|
log: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||||
|
fetchT: jest.fn(),
|
||||||
|
docker: null,
|
||||||
|
});
|
||||||
|
expect(loaded.config.events['dependency-restart']).toBe(false);
|
||||||
|
expect(loaded.config.events['dependency-restart-complete']).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('legacy 4-arg send shape shim', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Fresh providers object per test: on the no-config-file constructor
|
||||||
|
// path this.config.providers aliases module-level DEFAULT_CONFIG.providers,
|
||||||
|
// so per-provider mutation in one test otherwise leaks into the next.
|
||||||
|
nm.config.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: '' },
|
||||||
|
};
|
||||||
|
nm.config.providers.ntfy = { enabled: true, topic: 'dc094', serverUrl: 'https://ntfy.sh' };
|
||||||
|
nm.ctx.fetchT = jest.fn().mockResolvedValue({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
const ntfyCall = (n) => n.ctx.fetchT.mock.calls.find(c => String(c[0]).includes('ntfy.sh'));
|
||||||
|
const discordCall = (n) => n.ctx.fetchT.mock.calls.find(c => String(c[0]).includes('hook.test'));
|
||||||
|
|
||||||
|
test('send(event, title, message, type) delivers the message as body', async () => {
|
||||||
|
const result = await nm.send('deploymentFailed', 'Recipe Failed', 'Failed to deploy **plex**: boom', 'error');
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
const body = ntfyCall(nm)[1].body;
|
||||||
|
expect(body).toBe('Failed to deploy **plex**: boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the explicit legacy title reaches the ntfy Title header', async () => {
|
||||||
|
await nm.send('deploymentFailed', 'Recipe Failed', 'boom', 'error');
|
||||||
|
const headers = ntfyCall(nm)[1].headers;
|
||||||
|
expect(headers.Title).toBe('Recipe Failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonical-title events without data.title still get the mapped title', async () => {
|
||||||
|
await nm.send('ssl-cert-expiry', { text: 'expiring' }, 'warning');
|
||||||
|
const headers = ntfyCall(nm)[1].headers;
|
||||||
|
expect(headers.Title).toBe('SSL Certificate Expiry');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Discord embed carries the explicit title and the right severity color', async () => {
|
||||||
|
nm.config.providers.discord = { enabled: true, webhookUrl: 'https://hook.test/x' };
|
||||||
|
nm.config.providers.ntfy = { enabled: false };
|
||||||
|
await nm.send('deploymentFailed', 'Recipe Failed', 'boom', 'error');
|
||||||
|
const payload = JSON.parse(discordCall(nm)[1].body);
|
||||||
|
expect(payload.embeds[0].title).toBe('Recipe Failed');
|
||||||
|
expect(payload.embeds[0].description).toBe('boom');
|
||||||
|
expect(payload.embeds[0].color).toBe(15158332); // error/red, not the info-blue fallback
|
||||||
|
});
|
||||||
|
|
||||||
|
test('email subject uses the explicit title', async () => {
|
||||||
|
nm.config.providers.email = { enabled: true, host: 'smtp.test', port: 587, to: 'a@b.c', from: 'd@e.f', username: '', password: '' };
|
||||||
|
nm.config.providers.ntfy = { enabled: false };
|
||||||
|
await nm.send('deploymentSuccess', 'Recipe Deployed', 'plex deployed', 'success');
|
||||||
|
expect(mailMock.mock.calls.length).toBeGreaterThan(0);
|
||||||
|
const last = mailMock.mock.calls[mailMock.mock.calls.length - 1];
|
||||||
|
expect(last[0].subject).toBe('Recipe Deployed');
|
||||||
|
expect(last[0].text).toBe('plex deployed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history records the canonical event and the explicit title', async () => {
|
||||||
|
await nm.send('recipeRemoved', 'Recipe Removed', 'Removed **plex** recipe (3 containers).', 'info');
|
||||||
|
const entry = nm.getHistory()[0];
|
||||||
|
expect(entry.event).toBe('recipe-removed');
|
||||||
|
expect(entry.title).toBe('Recipe Removed');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('3-arg object calls are unchanged (no regression)', async () => {
|
||||||
|
await nm.send('alert', { text: 'resource spike' }, 'warning');
|
||||||
|
const body = ntfyCall(nm)[1].body;
|
||||||
|
expect(body).toBe('resource spike');
|
||||||
|
const headers = ntfyCall(nm)[1].headers;
|
||||||
|
expect(headers.Title).toBe('Resource Alert');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shim is type-guarded: a 4th arg with object data is not rewritten', async () => {
|
||||||
|
const data = { text: 'kept' };
|
||||||
|
await nm.send('alert', data, 'warning', 'stray-extra');
|
||||||
|
// Object data passes through untouched (stray 4th arg ignored, not
|
||||||
|
// treated as a legacy type) — the shim only fires for legacy
|
||||||
|
// string-title calls.
|
||||||
|
const body = ntfyCall(nm)[1].body;
|
||||||
|
expect(body).toBe('kept');
|
||||||
|
const entry = nm.getHistory()[0];
|
||||||
|
expect(entry.type).toBe('warning');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -419,7 +419,10 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
const notificationMessage = usedExisting
|
const notificationMessage = usedExisting
|
||||||
? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}`
|
? `**${template.name}** configured using existing container.\nURL: ${serviceUrl}`
|
||||||
: `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`;
|
: `**${template.name}** has been deployed successfully.\nURL: ${serviceUrl}`;
|
||||||
ctx.notification.send('deploymentSuccess', usedExisting ? 'Configuration Complete' : 'Deployment Successful', notificationMessage, 'success');
|
ctx.notification.send('deploymentSuccess', {
|
||||||
|
title: usedExisting ? 'Configuration Complete' : 'Deployment Successful',
|
||||||
|
text: notificationMessage
|
||||||
|
}, 'success');
|
||||||
|
|
||||||
res.json(response);
|
res.json(response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -427,7 +430,10 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag
|
|||||||
const msg = error?.message || String(error || 'Unknown error');
|
const msg = error?.message || String(error || 'Unknown error');
|
||||||
log.error('deploy', error, null, { note: 'Deployment failed', appId });
|
log.error('deploy', error, null, { note: 'Deployment failed', appId });
|
||||||
const template = ctx.APP_TEMPLATES[appId];
|
const template = ctx.APP_TEMPLATES[appId];
|
||||||
try { ctx.notification.send('deploymentFailed', 'Deployment Failed', `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`, 'error'); } catch (_) {}
|
try { ctx.notification.send('deploymentFailed', {
|
||||||
|
title: 'Deployment Failed',
|
||||||
|
text: `Failed to deploy **${template?.name || appId}**.\nError: ${msg}`
|
||||||
|
}, 'error'); } catch (_) {}
|
||||||
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
errorResponse(res, 500, ctx.safeErrorMessage(error));
|
||||||
}
|
}
|
||||||
}, 'apps-deploy'));
|
}, 'apps-deploy'));
|
||||||
|
|||||||
@@ -478,8 +478,10 @@ module.exports = function(ctx) {
|
|||||||
if (anyConfigured) {
|
if (anyConfigured) {
|
||||||
notification.send(
|
notification.send(
|
||||||
'deploymentSuccess',
|
'deploymentSuccess',
|
||||||
'Arr Stack Auto-Connected',
|
{
|
||||||
`Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`,
|
title: 'Arr Stack Auto-Connected',
|
||||||
|
text: `Overseerr configured: ${Object.entries(configResults).filter(([k,v]) => v === 'configured').map(([k]) => k).join(', ')}`
|
||||||
|
},
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -313,8 +313,10 @@ module.exports = function({ credentialManager, servicesStateManager, fetchT, asy
|
|||||||
if (succeeded > 0) {
|
if (succeeded > 0) {
|
||||||
ctx.notification.send(
|
ctx.notification.send(
|
||||||
'deploymentSuccess',
|
'deploymentSuccess',
|
||||||
'Smart Arr Connect Complete',
|
{
|
||||||
`${succeeded}/${steps.length} steps completed successfully`,
|
title: 'Smart Arr Connect Complete',
|
||||||
|
text: `${succeeded}/${steps.length} steps completed successfully`
|
||||||
|
},
|
||||||
'success'
|
'success'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -203,6 +203,11 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
backupComplete: 'backup-complete',
|
backupComplete: 'backup-complete',
|
||||||
backupFailed: 'backup-failed',
|
backupFailed: 'backup-failed',
|
||||||
autoRestart: 'auto-restart',
|
autoRestart: 'auto-restart',
|
||||||
|
// DC-094: same additions as the manager's EVENT_ALIASES — keep the
|
||||||
|
// two maps in sync so a key saved here is the key send() gates on.
|
||||||
|
recipeRemoved: 'recipe-removed',
|
||||||
|
'dependency-restart-complete': 'dependency-restart',
|
||||||
|
'dependency-restart-failed': 'dependency-restart',
|
||||||
};
|
};
|
||||||
const folded = {};
|
const folded = {};
|
||||||
for (const [k, v] of Object.entries(events)) {
|
for (const [k, v] of Object.entries(events)) {
|
||||||
@@ -264,7 +269,7 @@ module.exports = function({ notification, asyncHandler, ok }) {
|
|||||||
res.json({ success: result.success, provider, error: result.error });
|
res.json({ success: result.success, provider, error: result.error });
|
||||||
} else {
|
} else {
|
||||||
// Test all enabled providers
|
// Test all enabled providers
|
||||||
const result = await notification.send('test', 'Test Notification', 'This is a test notification from DashCaddy.', 'info');
|
const result = await notification.send('test', { title: 'Test Notification', text: 'This is a test notification from DashCaddy.' }, 'info');
|
||||||
ok(res, { ...result });
|
ok(res, { ...result });
|
||||||
}
|
}
|
||||||
}, 'notifications-test'));
|
}, 'notifications-test'));
|
||||||
|
|||||||
@@ -142,10 +142,10 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
setupInstructions: recipe.setupInstructions
|
setupInstructions: recipe.setupInstructions
|
||||||
};
|
};
|
||||||
|
|
||||||
ctx.notification.send('deploymentSuccess', 'Recipe Deployed',
|
ctx.notification.send('deploymentSuccess', {
|
||||||
`**${recipe.name}** recipe deployed (${deployedComponents.length} components).`,
|
title: 'Recipe Deployed',
|
||||||
'success'
|
text: `**${recipe.name}** recipe deployed (${deployedComponents.length} components).`
|
||||||
);
|
}, 'success');
|
||||||
|
|
||||||
ok(res, response);
|
ok(res, response);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -175,9 +175,10 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.notification.send('deploymentFailed', 'Recipe Failed',
|
ctx.notification.send('deploymentFailed', {
|
||||||
`Failed to deploy **${recipe.name}**: ${error.message}`, 'error'
|
title: 'Recipe Failed',
|
||||||
);
|
text: `Failed to deploy **${recipe.name}**: ${error.message}`
|
||||||
|
}, 'error');
|
||||||
|
|
||||||
// Error automatically handled by middleware
|
// Error automatically handled by middleware
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -273,10 +273,10 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx.notification.send('recipeRemoved', 'Recipe Removed',
|
ctx.notification.send('recipeRemoved', {
|
||||||
`Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`,
|
title: 'Recipe Removed',
|
||||||
'info'
|
text: `Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`
|
||||||
);
|
}, 'info');
|
||||||
|
|
||||||
log.info('recipe', 'Recipe removed', { recipeId, results });
|
log.info('recipe', 'Recipe removed', { recipeId, results });
|
||||||
ok(res, { recipeId, results });
|
ok(res, { recipeId, results });
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const nodemailer = require('nodemailer');
|
|||||||
// 'auto-restart' (resource-monitor.js) were absent from DEFAULT events and
|
// 'auto-restart' (resource-monitor.js) were absent from DEFAULT events and
|
||||||
// silently dropped, and UI camelCase toggles never reached the kebab keys the
|
// silently dropped, and UI camelCase toggles never reached the kebab keys the
|
||||||
// gate reads — the toggles were cosmetic.
|
// 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 = {
|
const EVENT_ALIASES = {
|
||||||
containerDown: 'container-down',
|
containerDown: 'container-down',
|
||||||
containerUp: 'container-up',
|
containerUp: 'container-up',
|
||||||
@@ -27,6 +29,11 @@ const EVENT_ALIASES = {
|
|||||||
backupComplete: 'backup-complete',
|
backupComplete: 'backup-complete',
|
||||||
backupFailed: 'backup-failed',
|
backupFailed: 'backup-failed',
|
||||||
autoRestart: 'auto-restart',
|
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 = {
|
const DEFAULT_CONFIG = {
|
||||||
@@ -49,7 +56,23 @@ const DEFAULT_CONFIG = {
|
|||||||
// silently dropped before this fix.
|
// silently dropped before this fix.
|
||||||
'deploy-success': true,
|
'deploy-success': true,
|
||||||
'deploy-failed': true,
|
'deploy-failed': true,
|
||||||
'auto-restart': 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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -189,6 +212,25 @@ class NotificationManager extends EventEmitter {
|
|||||||
* Send notification via all enabled providers
|
* Send notification via all enabled providers
|
||||||
*/
|
*/
|
||||||
async send(event, data, type = 'info') {
|
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) {
|
if (!this.config.enabled) {
|
||||||
return { success: false, error: 'Notifications disabled' };
|
return { success: false, error: 'Notifications disabled' };
|
||||||
}
|
}
|
||||||
@@ -204,6 +246,12 @@ class NotificationManager extends EventEmitter {
|
|||||||
return { success: false, error: `Event ${canonical} not enabled` };
|
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 results = [];
|
||||||
const providers = this.config.providers;
|
const providers = this.config.providers;
|
||||||
|
|
||||||
@@ -230,7 +278,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
// ntfy
|
// ntfy
|
||||||
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
|
if (providers.ntfy?.enabled && providers.ntfy?.topic) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendNtfy(this._formatText(data, canonical), this._formatTitle(canonical));
|
const result = await this.sendNtfy(this._formatText(data, canonical), title);
|
||||||
results.push({ provider: 'ntfy', ...result });
|
results.push({ provider: 'ntfy', ...result });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ provider: 'ntfy', success: false, error: error.message });
|
results.push({ provider: 'ntfy', success: false, error: error.message });
|
||||||
@@ -241,7 +289,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
|
if (providers.email?.enabled && providers.email?.host && providers.email?.to) {
|
||||||
try {
|
try {
|
||||||
const result = await this.sendEmail(
|
const result = await this.sendEmail(
|
||||||
this._formatTitle(canonical),
|
title,
|
||||||
this._formatText(data, canonical)
|
this._formatText(data, canonical)
|
||||||
);
|
);
|
||||||
results.push({ provider: 'email', ...result });
|
results.push({ provider: 'email', ...result });
|
||||||
@@ -252,7 +300,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
|
|
||||||
const allSucceeded = results.every(r => r.success);
|
const allSucceeded = results.every(r => r.success);
|
||||||
this._addToHistory({
|
this._addToHistory({
|
||||||
title: this._formatTitle(canonical),
|
title,
|
||||||
type,
|
type,
|
||||||
event: canonical,
|
event: canonical,
|
||||||
results
|
results
|
||||||
@@ -443,7 +491,14 @@ class NotificationManager extends EventEmitter {
|
|||||||
'test': 'Test Notification',
|
'test': 'Test Notification',
|
||||||
'auto-restart': 'Auto-Restart',
|
'auto-restart': 'Auto-Restart',
|
||||||
'deploy-success': 'Deployment Success',
|
'deploy-success': 'Deployment Success',
|
||||||
'deploy-failed': 'Deployment Failed'
|
'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';
|
return titles[event] || 'DashCaddy Notification';
|
||||||
}
|
}
|
||||||
@@ -458,7 +513,7 @@ class NotificationManager extends EventEmitter {
|
|||||||
if (data.embed) return data.embed;
|
if (data.embed) return data.embed;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: this._formatTitle(event),
|
title: (typeof data.title === 'string' && data.title) || this._formatTitle(event),
|
||||||
description: data.text || data.message || '',
|
description: data.text || data.message || '',
|
||||||
color: this._getTypeColor(type),
|
color: this._getTypeColor(type),
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
|
|||||||
@@ -500,7 +500,10 @@ class WorkflowEngine extends EventEmitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.info('workflow', 'Sending notification', { message });
|
log.info('workflow', 'Sending notification', { message });
|
||||||
notification.send('workflow', 'Workflow Notification', message, 'info');
|
notification.send('workflow', {
|
||||||
|
title: 'Workflow Notification',
|
||||||
|
text: message
|
||||||
|
}, 'info');
|
||||||
|
|
||||||
return { notified: true, message };
|
return { notified: true, message };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,33 @@
|
|||||||
<label class="checkbox-label-sm">
|
<label class="checkbox-label-sm">
|
||||||
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
|
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
|
||||||
</label>
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-ssl-cert-expiry" checked /> SSL Expiry
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-dns-propagation" checked /> DNS Propagation
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-drift-detected" checked /> Config Drift
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-dependency-restart" checked /> Dependency Restarts
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-recipe-removed" checked /> Recipe Removed
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-workflow" checked /> Workflow Actions
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-backup-complete" checked /> Backup Complete
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-backup-failed" checked /> Backup Failed
|
||||||
|
</label>
|
||||||
|
<label class="checkbox-label-sm">
|
||||||
|
<input type="checkbox" id="event-update-available" checked /> Updates
|
||||||
|
</label>
|
||||||
<label class="checkbox-label-sm" style="grid-column: 1 / -1;">
|
<label class="checkbox-label-sm" style="grid-column: 1 / -1;">
|
||||||
<input type="checkbox" id="event-resource-alert" checked /> Resource Alerts
|
<input type="checkbox" id="event-resource-alert" checked /> Resource Alerts
|
||||||
</label>
|
</label>
|
||||||
@@ -275,6 +302,15 @@
|
|||||||
document.getElementById('event-container-up').checked = config.events?.['container-up'] === true;
|
document.getElementById('event-container-up').checked = config.events?.['container-up'] === true;
|
||||||
document.getElementById('event-deploy-success').checked = config.events?.['deploy-success'] !== false;
|
document.getElementById('event-deploy-success').checked = config.events?.['deploy-success'] !== false;
|
||||||
document.getElementById('event-deploy-failed').checked = config.events?.['deploy-failed'] !== false;
|
document.getElementById('event-deploy-failed').checked = config.events?.['deploy-failed'] !== false;
|
||||||
|
document.getElementById('event-ssl-cert-expiry').checked = config.events?.['ssl-cert-expiry'] !== false;
|
||||||
|
document.getElementById('event-dns-propagation').checked = config.events?.['dns-propagation'] !== false;
|
||||||
|
document.getElementById('event-drift-detected').checked = config.events?.['drift-detected'] !== false;
|
||||||
|
document.getElementById('event-dependency-restart').checked = config.events?.['dependency-restart'] !== false;
|
||||||
|
document.getElementById('event-recipe-removed').checked = config.events?.['recipe-removed'] !== false;
|
||||||
|
document.getElementById('event-workflow').checked = config.events?.['workflow'] !== false;
|
||||||
|
document.getElementById('event-backup-complete').checked = config.events?.['backup-complete'] !== false;
|
||||||
|
document.getElementById('event-backup-failed').checked = config.events?.['backup-failed'] !== false;
|
||||||
|
document.getElementById('event-update-available').checked = config.events?.['update-available'] !== false;
|
||||||
document.getElementById('event-resource-alert').checked = config.events?.['alert'] !== false;
|
document.getElementById('event-resource-alert').checked = config.events?.['alert'] !== false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -352,6 +388,15 @@
|
|||||||
'container-up': document.getElementById('event-container-up').checked,
|
'container-up': document.getElementById('event-container-up').checked,
|
||||||
'deploy-success': document.getElementById('event-deploy-success').checked,
|
'deploy-success': document.getElementById('event-deploy-success').checked,
|
||||||
'deploy-failed': document.getElementById('event-deploy-failed').checked,
|
'deploy-failed': document.getElementById('event-deploy-failed').checked,
|
||||||
|
'ssl-cert-expiry': document.getElementById('event-ssl-cert-expiry').checked,
|
||||||
|
'dns-propagation': document.getElementById('event-dns-propagation').checked,
|
||||||
|
'drift-detected': document.getElementById('event-drift-detected').checked,
|
||||||
|
'dependency-restart': document.getElementById('event-dependency-restart').checked,
|
||||||
|
'recipe-removed': document.getElementById('event-recipe-removed').checked,
|
||||||
|
'workflow': document.getElementById('event-workflow').checked,
|
||||||
|
'backup-complete': document.getElementById('event-backup-complete').checked,
|
||||||
|
'backup-failed': document.getElementById('event-backup-failed').checked,
|
||||||
|
'update-available': document.getElementById('event-update-available').checked,
|
||||||
'alert': document.getElementById('event-resource-alert').checked
|
'alert': document.getElementById('event-resource-alert').checked
|
||||||
},
|
},
|
||||||
healthCheck: {
|
healthCheck: {
|
||||||
|
|||||||
Reference in New Issue
Block a user