Files
dashcaddy/status/js/notification-settings.js
T
Hermes 1328cfda6b [glm-grade=B] fix(notifications): repair UI/API contract drift — SMTP auth, event gate, test button (DC-092)
Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:

1. SMTP auth never applied: the UI sent email.user/email.pass while the
   manager read username/password. Route now normalizes aliases; legacy
   config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
   the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
   folds every known spelling (manager gate, route store, legacy files);
   UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
   deploy-failed/auto-restart were missing from DEFAULT events, and
   send('test') was itself gated -> the Test button was a no-op. Defaults
   added; 'test' bypasses the gate.

Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.

Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).

Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
2026-08-22 18:16:47 -07:00

483 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ========== NOTIFICATION SETTINGS ==========
(function() {
const errorHandler = new ErrorHandler();
// Inject modal HTML
injectModal('notifications-modal', `<div id="notifications-modal" class="weather-modal">
<div class="weather-modal-content" style="min-width: 550px; max-width: 650px;">
<h3>🔔 Notification Settings</h3>
<!-- Master Toggle -->
<div class="accent-info-box">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer;">
<input type="checkbox" id="notifications-enabled" />
<div>
<span style="font-weight: 600; color: var(--accent);">Enable Notifications</span>
<div class="text-tiny-muted">Receive alerts when containers go up/down</div>
</div>
</label>
</div>
<!-- Providers Section -->
<h4 class="section-heading">Notification Providers</h4>
<!-- Discord -->
<div class="notification-provider provider-card">
<div class="provider-header">
<label class="checkbox-label">
<input type="checkbox" id="discord-enabled" />
<span class="fw-500">Discord</span>
</label>
<button id="discord-test" class="test-btn btn-xs">Test</button>
</div>
<div id="discord-config" style="display: none;">
<label class="field-label-sm">Webhook URL:</label>
<input type="text" id="discord-webhook" placeholder="https://discord.com/api/v1/webhooks/..." style="width: 100%;" />
</div>
</div>
<!-- Telegram -->
<div class="notification-provider provider-card">
<div class="provider-header">
<label class="checkbox-label">
<input type="checkbox" id="telegram-enabled" />
<span class="fw-500">Telegram</span>
</label>
<button id="telegram-test" class="test-btn btn-xs">Test</button>
</div>
<div id="telegram-config" style="display: none;">
<div class="grid-2col">
<div>
<label class="field-label-sm">Bot Token:</label>
<input type="text" id="telegram-bot-token" placeholder="123456:ABC..." />
</div>
<div>
<label class="field-label-sm">Chat ID:</label>
<input type="text" id="telegram-chat-id" placeholder="-1001234567890" />
</div>
</div>
</div>
</div>
<!-- ntfy.sh -->
<div class="notification-provider provider-card">
<div class="provider-header">
<label class="checkbox-label">
<input type="checkbox" id="ntfy-enabled" />
<span class="fw-500">ntfy.sh</span>
</label>
<button id="ntfy-test" class="test-btn btn-xs">Test</button>
</div>
<div id="ntfy-config" style="display: none;">
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px;">
<div>
<label class="field-label-sm">Server URL:</label>
<input type="text" id="ntfy-server" placeholder="https://ntfy.sh" value="https://ntfy.sh" />
</div>
<div>
<label class="field-label-sm">Topic:</label>
<input type="text" id="ntfy-topic" placeholder="dashcaddy-alerts" />
</div>
</div>
</div>
</div>
<!-- Email -->
<div class="notification-provider provider-card">
<div class="provider-header">
<label class="checkbox-label">
<input type="checkbox" id="email-enabled" />
<span class="fw-500">Email (SMTP)</span>
</label>
<button id="email-test" class="test-btn btn-xs">Test</button>
</div>
<div id="email-config" style="display: none;">
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 8px; margin-bottom: 8px;">
<div>
<label class="field-label-sm">SMTP Host:</label>
<input type="text" id="email-host" placeholder="smtp.gmail.com" />
</div>
<div>
<label class="field-label-sm">Port:</label>
<input type="number" id="email-port" value="587" placeholder="587" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;">
<div>
<label class="field-label-sm">Username:</label>
<input type="text" id="email-user" placeholder="user@gmail.com" />
</div>
<div>
<label class="field-label-sm">Password:</label>
<input type="password" id="email-pass" placeholder="app password" />
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px;">
<div>
<label class="field-label-sm">From:</label>
<input type="text" id="email-from" placeholder="DashCaddy &lt;noreply@example.com&gt;" />
</div>
<div>
<label class="field-label-sm">To:</label>
<input type="text" id="email-to" placeholder="admin@example.com" />
</div>
</div>
<label style="display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 0.8rem;">
<input type="checkbox" id="email-secure" /> Use TLS (port 465)
</label>
</div>
</div>
<!-- Health Check Settings -->
<h4 class="section-heading">Health Monitoring</h4>
<div style="padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
<input type="checkbox" id="health-check-enabled" />
<div>
<span class="fw-500">Enable Health Monitoring</span>
<div class="text-tiny-muted">Periodically check container status</div>
</div>
</label>
<div id="health-check-config" style="display: flex; align-items: center; gap: 10px;">
<label class="field-label-sm">Check interval:</label>
<select id="health-check-interval" style="width: auto;">
<option value="1">1 minute</option>
<option value="5" selected>5 minutes</option>
<option value="15">15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">1 hour</option>
</select>
<button id="health-check-now" style="padding: 4px 10px; font-size: 0.75rem; margin-left: auto;">Check Now</button>
</div>
<div id="health-check-status" style="font-size: 0.75rem; color: var(--muted); margin-top: 8px;">
Last check: Never
</div>
</div>
<!-- Event Types -->
<h4 class="section-heading">Events to Notify</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 12px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border);">
<label class="checkbox-label-sm">
<input type="checkbox" id="event-container-down" checked /> Container Down
</label>
<label class="checkbox-label-sm">
<input type="checkbox" id="event-container-up" checked /> Container Recovered
</label>
<label class="checkbox-label-sm">
<input type="checkbox" id="event-deploy-success" checked /> Deployment Success
</label>
<label class="checkbox-label-sm">
<input type="checkbox" id="event-deploy-failed" checked /> Deployment Failed
</label>
<label class="checkbox-label-sm" style="grid-column: 1 / -1;">
<input type="checkbox" id="event-resource-alert" checked /> Resource Alerts
</label>
</div>
<!-- History -->
<h4 class="section-heading">Notification History</h4>
<div id="notification-history" style="max-height: 150px; overflow-y: auto; padding: 8px; background: var(--card-base); border-radius: 8px; border: 1px solid var(--border); font-size: 0.8rem;">
<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>
</div>
<div id="last-notification-sent" style="font-size: 0.75rem; color: var(--muted); text-align: center; margin-top: 6px;"></div>
<!-- Buttons -->
<div class="weather-modal-buttons modal-footer-bar">
<button id="notifications-send-test" class="btn-secondary" style="margin-right: auto;">Send Test</button>
<button id="notifications-cancel">Cancel</button>
<button id="notifications-save" class="btn-accent">Save Settings</button>
</div>
</div>
</div>`);
const modal = document.getElementById('notifications-modal');
const openBtn = document.getElementById('manage-notifications');
const saveBtn = document.getElementById('notifications-save');
const cancelBtn = document.getElementById('notifications-cancel');
// Provider toggle handlers
['discord', 'telegram', 'ntfy', 'email'].forEach(provider => {
const checkbox = document.getElementById(`${provider}-enabled`);
const config = document.getElementById(`${provider}-config`);
checkbox?.addEventListener('change', () => {
config.style.display = checkbox.checked ? 'block' : 'none';
});
});
// Health check toggle
const healthCheckEnabled = document.getElementById('health-check-enabled');
const healthCheckConfig = document.getElementById('health-check-config');
healthCheckEnabled?.addEventListener('change', () => {
healthCheckConfig.style.opacity = healthCheckEnabled.checked ? '1' : '0.5';
});
// Load notification config from API
async function loadNotificationConfig() {
try {
const response = await fetch('/api/v1/notifications/config');
const data = await response.json();
if (data.success) {
const config = data.config;
// Master toggle
document.getElementById('notifications-enabled').checked = config.enabled;
// Providers
document.getElementById('discord-enabled').checked = config.providers?.discord?.enabled || false;
document.getElementById('telegram-enabled').checked = config.providers?.telegram?.enabled || false;
document.getElementById('ntfy-enabled').checked = config.providers?.ntfy?.enabled || false;
document.getElementById('email-enabled').checked = config.providers?.email?.enabled || false;
// Show/hide config sections
document.getElementById('discord-config').style.display = config.providers?.discord?.enabled ? 'block' : 'none';
document.getElementById('telegram-config').style.display = config.providers?.telegram?.enabled ? 'block' : 'none';
document.getElementById('ntfy-config').style.display = config.providers?.ntfy?.enabled ? 'block' : 'none';
document.getElementById('email-config').style.display = config.providers?.email?.enabled ? 'block' : 'none';
// ntfy server URL
if (config.providers?.ntfy?.serverUrl) {
document.getElementById('ntfy-server').value = config.providers.ntfy.serverUrl;
}
// email fields — DC-092: prefill the FULL form so a save doesn't
// silently wipe fields the GET response previously omitted. Password
// is never returned; when one is stored the field shows a keep-hint
// and an empty submit preserves the stored credential server-side.
if (config.providers?.email?.host) document.getElementById('email-host').value = config.providers.email.host;
if (config.providers?.email?.from) document.getElementById('email-from').value = config.providers.email.from;
if (config.providers?.email?.to) document.getElementById('email-to').value = config.providers.email.to;
if (config.providers?.email?.port) document.getElementById('email-port').value = config.providers.email.port;
if (config.providers?.email?.secure !== undefined) document.getElementById('email-secure').checked = config.providers.email.secure === true;
if (config.providers?.email?.username) document.getElementById('email-user').value = config.providers.email.username;
const emailPassEl = document.getElementById('email-pass');
if (config.providers?.email?.hasPassword) {
emailPassEl.value = '';
emailPassEl.placeholder = 'saved — leave blank to keep';
} else {
emailPassEl.placeholder = 'app password';
}
// Health check
document.getElementById('health-check-enabled').checked = config.healthCheck?.enabled || false;
if (config.healthCheck?.intervalMinutes) {
document.getElementById('health-check-interval').value = config.healthCheck.intervalMinutes;
}
if (config.healthCheck?.lastCheck) {
document.getElementById('health-check-status').textContent =
`Last check: ${new Date(config.healthCheck.lastCheck).toLocaleString()}`;
}
// Events — canonical kebab-case keys, matching the backend store
// (DC-092: previously read camelCase keys that never existed, so
// every toggle re-rendered as 'checked' regardless of stored state).
document.getElementById('event-container-down').checked = config.events?.['container-down'] !== false;
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-resource-alert').checked = config.events?.['alert'] !== false;
}
} catch (error) {
errorHandler.logError('[Notifications] Load Config', error, { function: 'loadConfig' });
}
}
// Load notification history
async function loadNotificationHistory() {
try {
const response = await fetch('/api/v1/notifications/history?limit=10');
const data = await response.json();
const container = document.getElementById('notification-history');
if (data.success && data.history?.length > 0) {
container.innerHTML = data.history.map(item => {
const date = new Date(item.timestamp).toLocaleString();
const typeColors = {
success: 'var(--ok-fg)',
error: 'var(--bad-fg)',
warning: '#f39c12',
info: 'var(--accent)'
};
return `
<div style="padding: 6px 8px; border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: flex-start;">
<span style="color: ${typeColors[item.type] || 'var(--muted)'}">${item.type === 'success' ? '✓' : item.type === 'error' ? '✗' : ''}</span>
<div style="flex: 1; min-width: 0;">
<div style="font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${escapeHtml(item.title)}</div>
<div style="font-size: 0.7rem; color: var(--muted);">${date}</div>
</div>
</div>
`;
}).join('');
} else {
container.innerHTML = '<div style="color: var(--muted); text-align: center; padding: 20px;">No notifications yet</div>';
}
} catch (error) {
errorHandler.logError('[Notifications] Load History', error, { function: 'loadHistory' });
}
}
// Save notification config
async function saveNotificationConfig() {
try {
const config = {
enabled: document.getElementById('notifications-enabled').checked,
providers: {
discord: {
enabled: document.getElementById('discord-enabled').checked,
webhookUrl: document.getElementById('discord-webhook').value.trim()
},
telegram: {
enabled: document.getElementById('telegram-enabled').checked,
botToken: document.getElementById('telegram-bot-token').value.trim(),
chatId: document.getElementById('telegram-chat-id').value.trim()
},
ntfy: {
enabled: document.getElementById('ntfy-enabled').checked,
serverUrl: document.getElementById('ntfy-server').value.trim() || 'https://ntfy.sh',
topic: document.getElementById('ntfy-topic').value.trim()
},
email: {
enabled: document.getElementById('email-enabled').checked,
host: document.getElementById('email-host').value.trim(),
port: parseInt(document.getElementById('email-port').value) || 587,
secure: document.getElementById('email-secure').checked,
username: document.getElementById('email-user').value.trim(),
password: document.getElementById('email-pass').value.trim(),
from: document.getElementById('email-from').value.trim(),
to: document.getElementById('email-to').value.trim()
}
},
events: {
'container-down': document.getElementById('event-container-down').checked,
'container-up': document.getElementById('event-container-up').checked,
'deploy-success': document.getElementById('event-deploy-success').checked,
'deploy-failed': document.getElementById('event-deploy-failed').checked,
'alert': document.getElementById('event-resource-alert').checked
},
healthCheck: {
enabled: document.getElementById('health-check-enabled').checked,
intervalMinutes: parseInt(document.getElementById('health-check-interval').value) || 5
}
};
const response = await secureFetch('/api/v1/notifications/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await response.json();
if (data.success) {
showNotification('Notification settings saved', 'success', 3000);
modal.classList.remove('show');
} else {
showNotification(`Failed to save: ${data.error}`, 'error', 3000);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error', 3000);
}
}
// Test notification handlers
async function testProvider(provider) {
try {
const response = await secureFetch('/api/v1/notifications/test', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ provider })
});
const data = await response.json();
if (data.success) {
showNotification(`Test ${provider} notification sent!`, 'success', 3000);
} else {
showNotification(`Test failed: ${data.error}`, 'error', 3000);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error', 3000);
}
}
document.getElementById('discord-test')?.addEventListener('click', () => testProvider('discord'));
document.getElementById('telegram-test')?.addEventListener('click', () => testProvider('telegram'));
document.getElementById('ntfy-test')?.addEventListener('click', () => testProvider('ntfy'));
document.getElementById('email-test')?.addEventListener('click', () => testProvider('email'));
// Health check now button
document.getElementById('health-check-now')?.addEventListener('click', async () => {
try {
const response = await secureFetch('/api/v1/notifications/health-check', { method: 'POST' });
const data = await response.json();
if (data.success) {
document.getElementById('health-check-status').textContent =
`Last check: ${new Date(data.lastCheck).toLocaleString()} (${data.containersMonitored} containers)`;
showNotification('Health check completed', 'success', 2000);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error', 3000);
}
});
// Modal handlers
openBtn?.addEventListener('click', () => {
modal.classList.add('show');
loadNotificationConfig();
loadNotificationHistory();
});
saveBtn?.addEventListener('click', saveNotificationConfig);
// Send Test button - sends test notification to all enabled providers
document.getElementById('notifications-send-test')?.addEventListener('click', async () => {
const btn = document.getElementById('notifications-send-test');
const originalText = btn.textContent;
btn.textContent = 'Sending...';
btn.disabled = true;
try {
const response = await secureFetch('/api/v1/notifications/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'test',
data: { message: 'This is a test notification from DashCaddy.' },
type: 'info'
})
});
const data = await response.json();
if (data.success) {
showNotification('Test notification sent!', 'success', 3000);
// Update last sent timestamp
loadNotificationStatus();
} else {
showNotification(`Test failed: ${data.results?.map(r => `${r.provider}: ${r.error || 'ok'}`).join(', ')}`, 'error', 5000);
}
} catch (error) {
showNotification(`Error: ${error.message}`, 'error', 3000);
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
});
// Load notification status (last sent timestamp)
async function loadNotificationStatus() {
try {
const response = await fetch('/api/v1/notifications/status');
const data = await response.json();
if (data.success && data.lastSent) {
const lastSentEl = document.getElementById('last-notification-sent');
if (lastSentEl) {
lastSentEl.textContent = `Last sent: ${new Date(data.lastSent).toLocaleString()}`;
}
}
} catch (error) {
// Silently fail - status is not critical
}
}
wireModal(modal, cancelBtn);
})();