Files
dashcaddy/status/js/notification-settings.js
Sami bdf3f247b1 feat: add 7 new features — exec shell, SSE events, compose import, docker resources, resource limits, email notifications, auto-updates
- Container exec/shell via WebSocket + xterm.js (subtle >_ button on cards)
- Live dashboard updates via SSE (resource alerts, health changes, update notices)
- Docker Compose import with YAML parsing, preview, and dependency-ordered deploy
- Volume & network management modal with disk usage overview
- CPU/memory resource limits on deploy and live update
- Email SMTP notifications (nodemailer) alongside Discord/Telegram/ntfy
- Scheduled auto-update scheduler with maintenance windows (daily/weekly/monthly)

New deps: ws, js-yaml, nodemailer

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-05 16:15:14 -07:00

408 lines
18 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() {
// 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>
</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>
<!-- Buttons -->
<div class="weather-modal-buttons modal-footer-bar">
<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
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;
// 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
document.getElementById('event-container-down').checked = config.events?.containerDown !== false;
document.getElementById('event-container-up').checked = config.events?.containerUp !== false;
document.getElementById('event-deploy-success').checked = config.events?.deploymentSuccess !== false;
document.getElementById('event-deploy-failed').checked = config.events?.deploymentFailed !== false;
}
} catch (error) {
console.error('Failed to load notification config:', error);
}
}
// 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) {
console.error('Failed to load notification history:', error);
}
}
// 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,
user: document.getElementById('email-user').value.trim(),
pass: document.getElementById('email-pass').value.trim(),
from: document.getElementById('email-from').value.trim(),
to: document.getElementById('email-to').value.trim()
}
},
events: {
containerDown: document.getElementById('event-container-down').checked,
containerUp: document.getElementById('event-container-up').checked,
deploymentSuccess: document.getElementById('event-deploy-success').checked,
deploymentFailed: document.getElementById('event-deploy-failed').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);
wireModal(modal, cancelBtn);
})();