diff --git a/dashcaddy-api/__tests__/bundled-workflows-health-check.test.js b/dashcaddy-api/__tests__/bundled-workflows-health-check.test.js index 7ace68a..351069d 100644 --- a/dashcaddy-api/__tests__/bundled-workflows-health-check.test.js +++ b/dashcaddy-api/__tests__/bundled-workflows-health-check.test.js @@ -253,8 +253,8 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + expect(healthResult.failingServices).toEqual(['svc-broken']); expect(notifyResult.success).toBe(true); expect(notify).toHaveBeenCalledTimes(1); - // notification.send signature: (category, title, message, level) - const sentMessage = notify.mock.calls[0][2]; + // DC-094 notification.send signature: (event, { title, text }, level) + const sentMessage = notify.mock.calls[0][1].text; expect(sentMessage).toBe('Health check failed for svc-broken'); expect(sentMessage).not.toContain('{{'); }); @@ -269,7 +269,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + ); 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); }); @@ -313,7 +313,7 @@ describe('WorkflowEngine._runActions — DC-044 root-cause (notify-on-failure + ); 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(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 // {{...}} template token leak into notification.send. 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(/\}\}/); // The new bundled template substitutes failingServices — make sure diff --git a/dashcaddy-api/__tests__/notification-manager-dc094.test.js b/dashcaddy-api/__tests__/notification-manager-dc094.test.js new file mode 100644 index 0000000..55d9abe --- /dev/null +++ b/dashcaddy-api/__tests__/notification-manager-dc094.test.js @@ -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'); + }); + }); +}); diff --git a/dashcaddy-api/routes/apps/deploy.js b/dashcaddy-api/routes/apps/deploy.js index eb100af..3e7909b 100644 --- a/dashcaddy-api/routes/apps/deploy.js +++ b/dashcaddy-api/routes/apps/deploy.js @@ -419,7 +419,10 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag const notificationMessage = usedExisting ? `**${template.name}** configured using existing container.\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); } catch (error) { @@ -427,7 +430,10 @@ module.exports = function({ docker, caddy, credentialManager, servicesStateManag const msg = error?.message || String(error || 'Unknown error'); log.error('deploy', error, null, { note: 'Deployment failed', 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)); } }, 'apps-deploy')); diff --git a/dashcaddy-api/routes/arr/config.js b/dashcaddy-api/routes/arr/config.js index d07f6c2..2d4fab5 100644 --- a/dashcaddy-api/routes/arr/config.js +++ b/dashcaddy-api/routes/arr/config.js @@ -478,8 +478,10 @@ module.exports = function(ctx) { if (anyConfigured) { notification.send( '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' ); } diff --git a/dashcaddy-api/routes/arr/smart-connect.js b/dashcaddy-api/routes/arr/smart-connect.js index 7893a93..cbabe21 100644 --- a/dashcaddy-api/routes/arr/smart-connect.js +++ b/dashcaddy-api/routes/arr/smart-connect.js @@ -313,8 +313,10 @@ module.exports = function({ credentialManager, servicesStateManager, fetchT, asy if (succeeded > 0) { ctx.notification.send( 'deploymentSuccess', - 'Smart Arr Connect Complete', - `${succeeded}/${steps.length} steps completed successfully`, + { + title: 'Smart Arr Connect Complete', + text: `${succeeded}/${steps.length} steps completed successfully` + }, 'success' ); } diff --git a/dashcaddy-api/routes/notifications.js b/dashcaddy-api/routes/notifications.js index e484522..e7c7671 100644 --- a/dashcaddy-api/routes/notifications.js +++ b/dashcaddy-api/routes/notifications.js @@ -203,6 +203,11 @@ module.exports = function({ notification, asyncHandler, ok }) { backupComplete: 'backup-complete', backupFailed: 'backup-failed', 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 = {}; 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 }); } else { // 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 }); } }, 'notifications-test')); diff --git a/dashcaddy-api/routes/recipes/deploy.js b/dashcaddy-api/routes/recipes/deploy.js index e4b4b22..8e9222e 100644 --- a/dashcaddy-api/routes/recipes/deploy.js +++ b/dashcaddy-api/routes/recipes/deploy.js @@ -142,10 +142,10 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi setupInstructions: recipe.setupInstructions }; - ctx.notification.send('deploymentSuccess', 'Recipe Deployed', - `**${recipe.name}** recipe deployed (${deployedComponents.length} components).`, - 'success' - ); + ctx.notification.send('deploymentSuccess', { + title: 'Recipe Deployed', + text: `**${recipe.name}** recipe deployed (${deployedComponents.length} components).` + }, 'success'); ok(res, response); } catch (error) { @@ -175,9 +175,10 @@ module.exports = function({ docker, credentialManager: _credentialManager, servi } } - ctx.notification.send('deploymentFailed', 'Recipe Failed', - `Failed to deploy **${recipe.name}**: ${error.message}`, 'error' - ); + ctx.notification.send('deploymentFailed', { + title: 'Recipe Failed', + text: `Failed to deploy **${recipe.name}**: ${error.message}` + }, 'error'); // Error automatically handled by middleware } diff --git a/dashcaddy-api/routes/recipes/manage.js b/dashcaddy-api/routes/recipes/manage.js index 66cd888..90fadb3 100644 --- a/dashcaddy-api/routes/recipes/manage.js +++ b/dashcaddy-api/routes/recipes/manage.js @@ -273,10 +273,10 @@ module.exports = function({ servicesStateManager, asyncHandler, log, docker, not } } - ctx.notification.send('recipeRemoved', 'Recipe Removed', - `Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).`, - 'info' - ); + ctx.notification.send('recipeRemoved', { + title: 'Recipe Removed', + text: `Removed **${recipeId}** recipe (${results.filter(r => r.status === 'removed').length} containers).` + }, 'info'); log.info('recipe', 'Recipe removed', { recipeId, results }); ok(res, { recipeId, results }); diff --git a/dashcaddy-api/src/managers/notification-manager.js b/dashcaddy-api/src/managers/notification-manager.js index 1909ca1..fcb88c0 100644 --- a/dashcaddy-api/src/managers/notification-manager.js +++ b/dashcaddy-api/src/managers/notification-manager.js @@ -15,6 +15,8 @@ const nodemailer = require('nodemailer'); // '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', @@ -27,6 +29,11 @@ const EVENT_ALIASES = { 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 = { @@ -49,7 +56,23 @@ const DEFAULT_CONFIG = { // silently dropped before this fix. 'deploy-success': 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 */ 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' }; } @@ -204,6 +246,12 @@ class NotificationManager extends EventEmitter { 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; @@ -230,7 +278,7 @@ class NotificationManager extends EventEmitter { // ntfy if (providers.ntfy?.enabled && providers.ntfy?.topic) { 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 }); } catch (error) { 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) { try { const result = await this.sendEmail( - this._formatTitle(canonical), + title, this._formatText(data, canonical) ); results.push({ provider: 'email', ...result }); @@ -252,7 +300,7 @@ class NotificationManager extends EventEmitter { const allSucceeded = results.every(r => r.success); this._addToHistory({ - title: this._formatTitle(canonical), + title, type, event: canonical, results @@ -443,7 +491,14 @@ class NotificationManager extends EventEmitter { 'test': 'Test Notification', 'auto-restart': 'Auto-Restart', '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'; } @@ -458,7 +513,7 @@ class NotificationManager extends EventEmitter { if (data.embed) return data.embed; return { - title: this._formatTitle(event), + title: (typeof data.title === 'string' && data.title) || this._formatTitle(event), description: data.text || data.message || '', color: this._getTypeColor(type), timestamp: new Date().toISOString() diff --git a/dashcaddy-api/src/recipes/bundled-workflows.js b/dashcaddy-api/src/recipes/bundled-workflows.js index 0fbe369..b5e14f4 100644 --- a/dashcaddy-api/src/recipes/bundled-workflows.js +++ b/dashcaddy-api/src/recipes/bundled-workflows.js @@ -500,7 +500,10 @@ class WorkflowEngine extends EventEmitter { } 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 }; } diff --git a/status/js/notification-settings.js b/status/js/notification-settings.js index 60b1d70..e3fc075 100644 --- a/status/js/notification-settings.js +++ b/status/js/notification-settings.js @@ -168,6 +168,33 @@ + + + + + + + + + @@ -275,6 +302,15 @@ 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-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; } } catch (error) { @@ -352,6 +388,15 @@ 'container-up': document.getElementById('event-container-up').checked, 'deploy-success': document.getElementById('event-deploy-success').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 }, healthCheck: {