- 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>
408 lines
18 KiB
JavaScript
408 lines
18 KiB
JavaScript
// ========== 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 <noreply@example.com>" />
|
||
</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);
|
||
})();
|