Files
container-census/web/notifications.js
Self Hosters 80cc2883f0 Add native Discord notification channel with rich embeds
- Add ChannelTypeDiscord constant and DiscordConfig struct to models
- Implement discord.go channel with color-coded embeds by event type
  - Red: container_stopped
  - Green: container_started, container_resumed
  - Blue: new_image
  - Orange: high_cpu, high_memory
  - Purple: anomalous_behavior
  - Gray: state_change, default
- Register Discord in notifier factory
- Add Discord validation in API handlers
- Update Next.js frontend with Discord option and webhook URL config
- Update vanilla JS frontend for Discord support
- Fix testNotificationChannel API response format mismatch

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 14:05:18 -05:00

1155 lines
42 KiB
JavaScript

// Notification System JavaScript
// Handles all notification-related functionality
// State
let notifications = [];
let channels = [];
let rules = [];
let silences = [];
let unreadCount = 0;
let currentNotifTab = 'inbox';
let showUnreadOnly = false;
// Initialize notification system
function initNotifications() {
setupNotificationEventListeners();
loadNotificationData();
// Refresh notifications every 30 seconds
setInterval(() => {
if (currentTab === 'notifications' || document.getElementById('notificationDropdown').classList.contains('show')) {
loadNotifications();
}
}, 30000);
}
// Setup event listeners
function setupNotificationEventListeners() {
// Modern toggle switches
const ruleEnabledToggle = document.getElementById('ruleEnabled');
const ruleEnabledLabel = document.getElementById('ruleEnabledLabel');
if (ruleEnabledToggle && ruleEnabledLabel) {
ruleEnabledToggle.addEventListener('change', (e) => {
ruleEnabledLabel.textContent = e.target.checked ? 'Enabled' : 'Disabled';
ruleEnabledLabel.classList.toggle('active', e.target.checked);
});
}
const channelEnabledToggle = document.getElementById('channelEnabled');
const channelEnabledLabel = document.getElementById('channelEnabledLabel');
if (channelEnabledToggle && channelEnabledLabel) {
channelEnabledToggle.addEventListener('change', (e) => {
channelEnabledLabel.textContent = e.target.checked ? 'Enabled' : 'Disabled';
channelEnabledLabel.classList.toggle('active', e.target.checked);
});
}
// Notification bell toggle
document.getElementById('notificationBell').addEventListener('click', (e) => {
e.stopPropagation();
toggleNotificationDropdown();
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
const dropdown = document.getElementById('notificationDropdown');
const bell = document.getElementById('notificationBell');
if (!dropdown.contains(e.target) && !bell.contains(e.target)) {
dropdown.classList.remove('show');
}
});
// Dropdown actions
document.getElementById('markAllRead').addEventListener('click', markAllNotificationsRead);
document.getElementById('viewAllNotifications').addEventListener('click', () => {
switchTab('notifications');
document.getElementById('notificationDropdown').classList.remove('show');
});
document.getElementById('clearNotifications').addEventListener('click', clearAllNotifications);
// Notification tab switching
document.querySelectorAll('.notification-tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const tab = btn.getAttribute('data-notif-tab');
switchNotificationTab(tab);
});
});
// Filter buttons
document.getElementById('filterAll').addEventListener('click', () => {
showUnreadOnly = false;
document.getElementById('filterAll').classList.add('active');
document.getElementById('filterUnread').classList.remove('active');
renderNotificationInbox();
});
document.getElementById('filterUnread').addEventListener('click', () => {
showUnreadOnly = true;
document.getElementById('filterUnread').classList.add('active');
document.getElementById('filterAll').classList.remove('active');
renderNotificationInbox();
});
// Inbox actions
document.getElementById('markAllReadBtn').addEventListener('click', markAllNotificationsRead);
document.getElementById('clearAllNotificationsBtn').addEventListener('click', clearAllNotifications);
// Channel actions
document.getElementById('addChannelBtn').addEventListener('click', openAddChannelModal);
document.getElementById('addChannelForm').addEventListener('submit', handleChannelSubmit);
document.getElementById('channelType').addEventListener('change', updateChannelConfigFields);
document.getElementById('testChannelBtn').addEventListener('click', testChannel);
// Rule actions
document.getElementById('addRuleBtn').addEventListener('click', openAddRuleModal);
document.getElementById('addRuleForm').addEventListener('submit', handleRuleSubmit);
// Silence actions
document.getElementById('addSilenceBtn').addEventListener('click', openAddSilenceModal);
document.getElementById('addSilenceForm').addEventListener('submit', handleAddSilence);
}
// Toggle notification dropdown
function toggleNotificationDropdown() {
const dropdown = document.getElementById('notificationDropdown');
dropdown.classList.toggle('show');
if (dropdown.classList.contains('show')) {
loadNotifications(10); // Load recent 10 for dropdown
renderNotificationDropdown();
}
}
// Load all notification data
async function loadNotificationData() {
await Promise.all([
loadNotifications(),
loadChannels(),
loadRules(),
loadSilences()
]);
updateNotificationBadge();
if (currentTab === 'notifications') {
renderCurrentNotificationTab();
}
}
// Load notifications
async function loadNotifications(limit = 100) {
try {
const url = showUnreadOnly
? `/api/notifications/logs?limit=${limit}&unread=true`
: `/api/notifications/logs?limit=${limit}`;
const response = await fetch(url);
if (!response.ok) {
const errorText = await response.text();
console.error('Failed to load notifications:', response.status, errorText);
throw new Error('Failed to load notifications');
}
const data = await response.json();
notifications = Array.isArray(data) ? data : [];
console.log('Loaded notifications:', notifications.length, 'notifications');
unreadCount = notifications.filter(n => !n.read).length;
updateNotificationBadge();
if (currentNotifTab === 'inbox') {
renderNotificationInbox();
}
} catch (error) {
console.error('Error loading notifications:', error);
notifications = [];
}
}
// Load channels
async function loadChannels() {
try {
const response = await fetch('/api/notifications/channels');
if (!response.ok) throw new Error('Failed to load channels');
channels = await response.json();
if (currentNotifTab === 'channels') {
renderChannelsList();
}
// Update rule modal channel selector
updateRuleChannelSelector();
} catch (error) {
console.error('Error loading channels:', error);
channels = [];
}
}
// Load rules
async function loadRules() {
try {
const response = await fetch('/api/notifications/rules');
if (!response.ok) throw new Error('Failed to load rules');
rules = await response.json();
if (currentNotifTab === 'rules') {
renderRulesList();
}
} catch (error) {
console.error('Error loading rules:', error);
rules = [];
}
}
// Load silences
async function loadSilences() {
try {
const response = await fetch('/api/notifications/silences');
if (!response.ok) throw new Error('Failed to load silences');
silences = await response.json();
if (currentNotifTab === 'silences') {
renderSilencesList();
}
} catch (error) {
console.error('Error loading silences:', error);
silences = [];
}
}
// Update notification badge
function updateNotificationBadge() {
const badge = document.getElementById('notificationBadge');
const sidebarBadge = document.getElementById('notificationsSidebarBadge');
if (unreadCount > 0) {
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
if (sidebarBadge) sidebarBadge.textContent = unreadCount;
} else {
badge.textContent = '';
if (sidebarBadge) sidebarBadge.textContent = '';
}
}
// Render notification dropdown
function renderNotificationDropdown() {
const list = document.getElementById('notificationList');
if (notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">No notifications</div>';
return;
}
list.innerHTML = notifications.slice(0, 10).map(notif => `
<div class="notification-item ${!notif.read ? 'unread' : ''}" onclick="markNotificationRead(${notif.id})">
<div class="notification-item-content">
<div class="notification-item-title">${getEventTypeIcon(notif.event_type)} ${notif.rule_name || 'Notification'}</div>
<div class="notification-item-message">${notif.message}</div>
<div class="notification-item-time">${formatTimeAgo(notif.sent_at)}</div>
</div>
</div>
`).join('');
}
// Render notification inbox
function renderNotificationInbox() {
const list = document.getElementById('notificationInboxList');
const filteredNotifs = showUnreadOnly
? notifications.filter(n => !n.read)
: notifications;
if (filteredNotifs.length === 0) {
list.innerHTML = '<div class="notification-empty">No notifications</div>';
return;
}
list.innerHTML = filteredNotifs.map(notif => `
<div class="notification-inbox-item ${!notif.read ? 'unread' : ''}" onclick="markNotificationRead(${notif.id})">
<div class="notification-inbox-header-row">
<span class="notification-inbox-type ${notif.event_type}">${getEventTypeName(notif.event_type)}</span>
<span class="notification-inbox-time">${formatTimestamp(notif.sent_at)}</span>
</div>
<div class="notification-inbox-message">${notif.message}</div>
<div class="notification-inbox-details">
${notif.container_name ? `<div class="notification-inbox-detail">📦 ${notif.container_name}</div>` : ''}
${notif.host_name ? `<div class="notification-inbox-detail">🖥️ ${notif.host_name}</div>` : ''}
${notif.image ? `<div class="notification-inbox-detail">🖼️ ${notif.image}</div>` : ''}
</div>
</div>
`).join('');
}
// Switch notification tab
function switchNotificationTab(tab) {
currentNotifTab = tab;
// Update tab buttons
document.querySelectorAll('.notification-tab-btn').forEach(btn => {
btn.classList.toggle('active', btn.getAttribute('data-notif-tab') === tab);
});
// Update tab content
document.querySelectorAll('.notif-tab-content').forEach(content => {
content.classList.remove('active');
});
document.getElementById(`${tab}NotifTab`).classList.add('active');
// Render appropriate content
renderCurrentNotificationTab();
}
// Render current notification tab
function renderCurrentNotificationTab() {
switch(currentNotifTab) {
case 'inbox':
renderNotificationInbox();
break;
case 'channels':
renderChannelsList();
break;
case 'rules':
renderRulesList();
break;
case 'silences':
renderSilencesList();
break;
}
}
// Render channels list
function renderChannelsList() {
const list = document.getElementById('channelsList');
if (channels.length === 0) {
list.innerHTML = '<div class="notification-empty">No channels configured</div>';
return;
}
list.innerHTML = channels.map(ch => `
<div class="channel-item">
<div class="channel-item-header">
<div class="channel-item-title">
${ch.name}
<span class="channel-type-badge ${ch.type}">${ch.type}</span>
<span class="status-badge ${ch.enabled ? 'enabled' : 'disabled'}">${ch.enabled ? 'Enabled' : 'Disabled'}</span>
</div>
<div class="channel-item-actions">
<button class="btn btn-sm btn-secondary" onclick="testChannelById(${ch.id})">Test</button>
<button class="btn btn-sm btn-secondary" onclick="editChannel(${ch.id})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteChannel(${ch.id})">Delete</button>
</div>
</div>
<div class="channel-item-body">
${renderChannelDetails(ch)}
</div>
</div>
`).join('');
}
// Render channel details based on type
function renderChannelDetails(channel) {
const config = channel.config || {};
switch(channel.type) {
case 'webhook':
return `
<div class="channel-detail"><span class="detail-label">URL:</span> <span class="detail-value">${config.url || 'N/A'}</span></div>
${config.headers ? `<div class="channel-detail"><span class="detail-label">Headers:</span> <span class="detail-value">Configured</span></div>` : ''}
`;
case 'discord':
return `
<div class="channel-detail"><span class="detail-label">Webhook:</span> <span class="detail-value">${config.webhook_url ? 'Configured' : 'N/A'}</span></div>
`;
case 'ntfy':
return `
<div class="channel-detail"><span class="detail-label">Server:</span> <span class="detail-value">${config.server_url || 'https://ntfy.sh'}</span></div>
<div class="channel-detail"><span class="detail-label">Topic:</span> <span class="detail-value">${config.topic || 'N/A'}</span></div>
${config.token ? `<div class="channel-detail"><span class="detail-label">Auth:</span> <span class="detail-value">Configured</span></div>` : ''}
`;
case 'in_app':
return '<div class="channel-detail"><span class="detail-value">In-app notifications only</span></div>';
default:
return '';
}
}
// Render rules list
function renderRulesList() {
const list = document.getElementById('rulesList');
if (rules.length === 0) {
list.innerHTML = '<div class="notification-empty">No rules configured</div>';
return;
}
list.innerHTML = rules.map(rule => {
// Get channel names and types for this rule
const ruleChannels = channels.filter(ch => rule.channel_ids.includes(ch.id));
const channelBadges = ruleChannels.map(ch =>
`<span class="rule-channel-badge ${ch.type}">${ch.name}</span>`
).join('');
return `
<div class="rule-item ${rule.enabled ? 'enabled' : 'disabled'}">
<div class="rule-item-header">
<div class="rule-item-title">
${rule.enabled ? '✅' : '❌'} ${rule.name}
</div>
<div class="rule-item-actions">
<button class="btn btn-sm btn-secondary" onclick="toggleRule(${rule.id}, ${!rule.enabled})">${rule.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn btn-sm btn-secondary" onclick="editRule(${rule.id})">Edit</button>
<button class="btn btn-sm btn-danger" onclick="deleteRule(${rule.id})">Delete</button>
</div>
</div>
<div class="rule-item-body">
<div class="rule-detail">
<span class="detail-label">📬 Channels:</span>
<div class="rule-channels-list">
${channelBadges || '<span class="detail-value">None</span>'}
</div>
</div>
<div class="rule-detail">
<span class="detail-label">📋 Events:</span>
<div class="event-types-list">
${rule.event_types.map(et => `<span class="event-type-tag">${getEventTypeIcon(et)} ${getEventTypeName(et)}</span>`).join('')}
</div>
</div>
${rule.container_pattern ? `<div class="rule-detail"><span class="detail-label">📦 Container Pattern:</span> <span class="detail-value">${rule.container_pattern}</span></div>` : ''}
${rule.image_pattern ? `<div class="rule-detail"><span class="detail-label">🖼️ Image Pattern:</span> <span class="detail-value">${rule.image_pattern}</span></div>` : ''}
${rule.cpu_threshold || rule.memory_threshold ? `<div class="rule-detail"><span class="detail-label">📊 Thresholds:</span> <span class="detail-value">${rule.cpu_threshold ? 'CPU: ' + rule.cpu_threshold + '%' : ''}${rule.cpu_threshold && rule.memory_threshold ? ', ' : ''}${rule.memory_threshold ? 'Memory: ' + rule.memory_threshold + '%' : ''}</span></div>` : ''}
<div class="rule-detail"><span class="detail-label">⏱️ Cooldown:</span> <span class="detail-value">${rule.cooldown_seconds}s</span></div>
</div>
</div>
`;
}).join('');
}
// Render silences list
function renderSilencesList() {
const list = document.getElementById('silencesList');
if (silences.length === 0) {
list.innerHTML = '<div class="notification-empty">No active silences</div>';
return;
}
list.innerHTML = silences.map(silence => `
<div class="silence-item">
<div class="silence-item-header">
<div class="silence-item-title">
${silence.reason || 'Silence'}
${silence.silenced_until ? `<span class="detail-value">(Expires: ${formatTimestamp(silence.silenced_until)})</span>` : ''}
</div>
<div class="silence-item-actions">
<button class="btn btn-sm btn-danger" onclick="deleteSilence(${silence.id})">Remove</button>
</div>
</div>
<div class="silence-item-body">
${silence.host_id ? `<div class="silence-detail"><span class="detail-label">Host ID:</span> <span class="detail-value">${silence.host_id}</span></div>` : ''}
${silence.container_id ? `<div class="silence-detail"><span class="detail-label">Container ID:</span> <span class="detail-value">${silence.container_id}</span></div>` : ''}
${silence.host_pattern ? `<div class="silence-detail"><span class="detail-label">Host Pattern:</span> <span class="detail-value">${silence.host_pattern}</span></div>` : ''}
${silence.container_pattern ? `<div class="silence-detail"><span class="detail-label">Container Pattern:</span> <span class="detail-value">${silence.container_pattern}</span></div>` : ''}
</div>
</div>
`).join('');
}
// Mark notification as read
async function markNotificationRead(id) {
try {
const response = await fetch(`/api/notifications/logs/${id}/read`, {
method: 'PUT'
});
if (response.ok) {
const notif = notifications.find(n => n.id === id);
if (notif) notif.read = true;
unreadCount = notifications.filter(n => !n.read).length;
updateNotificationBadge();
renderNotificationDropdown();
renderNotificationInbox();
}
} catch (error) {
console.error('Error marking notification as read:', error);
}
}
// Mark all notifications as read
async function markAllNotificationsRead() {
try {
const response = await fetch('/api/notifications/logs/read-all', {
method: 'PUT'
});
if (response.ok) {
notifications.forEach(n => n.read = true);
unreadCount = 0;
updateNotificationBadge();
renderNotificationDropdown();
renderNotificationInbox();
showToast('Success', 'All notifications marked as read', 'success');
}
} catch (error) {
console.error('Error marking all notifications as read:', error);
showToast('Error', 'Failed to mark notifications as read', 'error');
}
}
// Clear all notifications
async function clearAllNotifications() {
if (!confirm('Are you sure you want to clear all notifications?')) return;
try {
const response = await fetch('/api/notifications/logs/clear', {
method: 'DELETE'
});
if (response.ok) {
notifications = [];
unreadCount = 0;
updateNotificationBadge();
renderNotificationDropdown();
renderNotificationInbox();
showToast('Success', 'All notifications cleared', 'success');
}
} catch (error) {
console.error('Error clearing notifications:', error);
showToast('Error', 'Failed to clear notifications', 'error');
}
}
// Channel management
let currentChannelMode = 'add';
let currentChannelId = null;
function openAddChannelModal() {
currentChannelMode = 'add';
currentChannelId = null;
document.getElementById('addChannelForm').reset();
document.getElementById('channelType').value = '';
updateChannelConfigFields();
// Update modal title for "Add" mode
document.querySelector('#addChannelModal .modal-header h3').textContent = 'Add Notification Channel';
document.querySelector('#addChannelModal .btn-primary').textContent = 'Add Channel';
document.getElementById('addChannelModal').classList.add('show');
}
function closeAddChannelModal() {
document.getElementById('addChannelModal').classList.remove('show');
}
function updateChannelConfigFields() {
const type = document.getElementById('channelType').value;
document.getElementById('webhookConfig').style.display = type === 'webhook' ? 'block' : 'none';
document.getElementById('discordConfig').style.display = type === 'discord' ? 'block' : 'none';
document.getElementById('ntfyConfig').style.display = type === 'ntfy' ? 'block' : 'none';
}
async function handleChannelSubmit(e) {
e.preventDefault();
if (currentChannelMode === 'edit' && currentChannelId) {
await handleUpdateChannel(currentChannelId);
} else {
await handleAddChannel();
}
}
async function handleAddChannel() {
const type = document.getElementById('channelType').value;
const config = {};
if (type === 'webhook') {
config.url = document.getElementById('webhookURL').value;
const headersText = document.getElementById('webhookHeaders').value;
if (headersText) {
try {
config.headers = JSON.parse(headersText);
} catch (error) {
showToast('Error', 'Invalid JSON in headers field', 'error');
return;
}
}
} else if (type === 'discord') {
config.webhook_url = document.getElementById('discordWebhookURL').value;
} else if (type === 'ntfy') {
config.server_url = document.getElementById('ntfyServerURL').value || 'https://ntfy.sh';
config.topic = document.getElementById('ntfyTopic').value;
config.token = document.getElementById('ntfyToken').value || '';
}
const channel = {
name: document.getElementById('channelName').value,
type: type,
config: config,
enabled: document.getElementById('channelEnabled').checked
};
try {
const response = await fetch('/api/notifications/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(channel)
});
if (response.ok) {
await loadChannels();
closeAddChannelModal();
showToast('Success', 'Channel created successfully', 'success');
} else {
const error = await response.json();
showToast('Error', error.error || 'Failed to create channel', 'error');
}
} catch (error) {
console.error('Error creating channel:', error);
showToast('Error', 'Failed to create channel', 'error');
}
}
async function testChannelById(id) {
try {
const response = await fetch(`/api/notifications/channels/${id}/test`, {
method: 'POST'
});
if (response.ok) {
showToast('Success', 'Test notification sent', 'success');
// Reload notifications after a short delay to see the test
setTimeout(() => {
loadNotifications();
}, 500);
} else {
const error = await response.json();
showToast('Error', error.error || 'Failed to send test notification', 'error');
}
} catch (error) {
console.error('Error testing channel:', error);
showToast('Error', 'Failed to send test notification', 'error');
}
}
async function deleteChannel(id) {
if (!confirm('Are you sure you want to delete this channel?')) return;
try {
const response = await fetch(`/api/notifications/channels/${id}`, {
method: 'DELETE'
});
if (response.ok) {
await loadChannels();
showToast('Success', 'Channel deleted successfully', 'success');
} else {
showToast('Error', 'Failed to delete channel', 'error');
}
} catch (error) {
console.error('Error deleting channel:', error);
showToast('Error', 'Failed to delete channel', 'error');
}
}
// Rule management
let currentRuleMode = 'add';
let currentRuleId = null;
function openAddRuleModal() {
currentRuleMode = 'add';
currentRuleId = null;
document.getElementById('addRuleForm').reset();
populateRuleHostSelector();
updateRuleChannelSelector();
// Update modal title for "Add" mode
document.querySelector('#addRuleModal .modal-header h3').textContent = 'Add Notification Rule';
document.querySelector('#addRuleModal .btn-primary').textContent = 'Add Rule';
document.getElementById('addRuleModal').classList.add('show');
}
function closeAddRuleModal() {
document.getElementById('addRuleModal').classList.remove('show');
}
function populateRuleHostSelector() {
const selectors = [document.getElementById('ruleHost'), document.getElementById('silenceHost')];
selectors.forEach(select => {
if (select) {
select.innerHTML = '<option value="">All Hosts</option>' +
hosts.map(h => `<option value="${h.id}">${h.name}</option>`).join('');
}
});
}
function updateRuleChannelSelector() {
const select = document.getElementById('ruleChannels');
if (select) {
select.innerHTML = channels.map(ch =>
`<option value="${ch.id}">${ch.name} (${ch.type})</option>`
).join('');
}
}
async function handleRuleSubmit(e) {
e.preventDefault();
if (currentRuleMode === 'edit' && currentRuleId) {
await handleUpdateRule(currentRuleId);
} else {
await handleAddRule();
}
}
async function handleAddRule() {
const eventTypes = Array.from(document.querySelectorAll('input[name="eventTypes"]:checked'))
.map(cb => cb.value);
if (eventTypes.length === 0) {
showToast('Error', 'Please select at least one event type', 'error');
return;
}
const channelIds = Array.from(document.getElementById('ruleChannels').selectedOptions)
.map(opt => parseInt(opt.value));
if (channelIds.length === 0) {
showToast('Error', 'Please select at least one channel', 'error');
return;
}
const rule = {
name: document.getElementById('ruleName').value,
enabled: document.getElementById('ruleEnabled').checked,
event_types: eventTypes,
container_pattern: document.getElementById('ruleContainerPattern').value || '',
image_pattern: document.getElementById('ruleImagePattern').value || '',
threshold_duration_seconds: parseInt(document.getElementById('ruleThresholdDuration').value) || 120,
cooldown_seconds: parseInt(document.getElementById('ruleCooldown').value) || 300,
channel_ids: channelIds
};
const hostId = document.getElementById('ruleHost').value;
if (hostId) rule.host_id = parseInt(hostId);
const cpuThreshold = document.getElementById('ruleCPUThreshold').value;
if (cpuThreshold) rule.cpu_threshold = parseFloat(cpuThreshold);
const memThreshold = document.getElementById('ruleMemoryThreshold').value;
if (memThreshold) rule.memory_threshold = parseFloat(memThreshold);
try {
const response = await fetch('/api/notifications/rules', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule)
});
if (response.ok) {
await loadRules();
closeAddRuleModal();
showToast('Success', 'Rule created successfully', 'success');
} else {
const error = await response.json();
showToast('Error', error.error || 'Failed to create rule', 'error');
}
} catch (error) {
console.error('Error creating rule:', error);
showToast('Error', 'Failed to create rule', 'error');
}
}
async function toggleRule(id, enabled) {
const rule = rules.find(r => r.id === id);
if (!rule) return;
rule.enabled = enabled;
try {
const response = await fetch(`/api/notifications/rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule)
});
if (response.ok) {
await loadRules();
showToast('Success', `Rule ${enabled ? 'enabled' : 'disabled'}`, 'success');
} else {
showToast('Error', 'Failed to update rule', 'error');
}
} catch (error) {
console.error('Error toggling rule:', error);
showToast('Error', 'Failed to update rule', 'error');
}
}
async function deleteRule(id) {
if (!confirm('Are you sure you want to delete this rule?')) return;
try {
const response = await fetch(`/api/notifications/rules/${id}`, {
method: 'DELETE'
});
if (response.ok) {
await loadRules();
showToast('Success', 'Rule deleted successfully', 'success');
} else {
showToast('Error', 'Failed to delete rule', 'error');
}
} catch (error) {
console.error('Error deleting rule:', error);
showToast('Error', 'Failed to delete rule', 'error');
}
}
// Silence management
function openAddSilenceModal() {
document.getElementById('addSilenceForm').reset();
populateRuleHostSelector();
// Set default expiry to 1 hour from now
const now = new Date();
now.setHours(now.getHours() + 1);
document.getElementById('silenceEndsAt').value = now.toISOString().slice(0, 16);
document.getElementById('addSilenceModal').classList.add('show');
}
function closeAddSilenceModal() {
document.getElementById('addSilenceModal').classList.remove('show');
}
async function handleAddSilence(e) {
e.preventDefault();
const silence = {
reason: document.getElementById('silenceReason').value || '',
container_id: document.getElementById('silenceContainer').value || '',
host_pattern: document.getElementById('silenceHostPattern').value || '',
container_pattern: document.getElementById('silenceContainerPattern').value || '',
silenced_until: document.getElementById('silenceEndsAt').value || null
};
const hostId = document.getElementById('silenceHost').value;
if (hostId) silence.host_id = parseInt(hostId);
try {
const response = await fetch('/api/notifications/silences', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(silence)
});
if (response.ok) {
await loadSilences();
closeAddSilenceModal();
showToast('Success', 'Silence created successfully', 'success');
} else {
const error = await response.json();
showToast('Error', error.error || 'Failed to create silence', 'error');
}
} catch (error) {
console.error('Error creating silence:', error);
showToast('Error', 'Failed to create silence', 'error');
}
}
async function deleteSilence(id) {
if (!confirm('Are you sure you want to remove this silence?')) return;
try {
const response = await fetch(`/api/notifications/silences/${id}`, {
method: 'DELETE'
});
if (response.ok) {
await loadSilences();
showToast('Success', 'Silence removed successfully', 'success');
} else {
showToast('Error', 'Failed to remove silence', 'error');
}
} catch (error) {
console.error('Error removing silence:', error);
showToast('Error', 'Failed to remove silence', 'error');
}
}
// Edit Channel
function editChannel(id) {
const channel = channels.find(c => c.id === id);
if (!channel) return;
currentChannelMode = 'edit';
currentChannelId = id;
// Populate form with existing data
document.getElementById('channelName').value = channel.name;
document.getElementById('channelType').value = channel.type;
document.getElementById('channelEnabled').checked = channel.enabled;
// Update config fields based on type
updateChannelConfigFields();
if (channel.type === 'webhook') {
document.getElementById('webhookURL').value = channel.config.url || '';
if (channel.config.headers) {
document.getElementById('webhookHeaders').value = JSON.stringify(channel.config.headers, null, 2);
}
} else if (channel.type === 'discord') {
document.getElementById('discordWebhookURL').value = channel.config.webhook_url || '';
} else if (channel.type === 'ntfy') {
document.getElementById('ntfyServerURL').value = channel.config.server_url || 'https://ntfy.sh';
document.getElementById('ntfyTopic').value = channel.config.topic || '';
document.getElementById('ntfyToken').value = channel.config.token || '';
}
// Change modal title
document.querySelector('#addChannelModal .modal-header h3').textContent = 'Edit Channel';
document.querySelector('#addChannelModal .btn-primary').textContent = 'Update Channel';
document.getElementById('addChannelModal').classList.add('show');
}
async function handleUpdateChannel(id) {
const type = document.getElementById('channelType').value;
const config = {};
if (type === 'webhook') {
config.url = document.getElementById('webhookURL').value;
const headersText = document.getElementById('webhookHeaders').value;
if (headersText) {
try {
config.headers = JSON.parse(headersText);
} catch (error) {
showToast('Error', 'Invalid JSON in headers field', 'error');
return;
}
}
} else if (type === 'discord') {
config.webhook_url = document.getElementById('discordWebhookURL').value;
} else if (type === 'ntfy') {
config.server_url = document.getElementById('ntfyServerURL').value || 'https://ntfy.sh';
config.topic = document.getElementById('ntfyTopic').value;
config.token = document.getElementById('ntfyToken').value || '';
}
const channel = {
id: id,
name: document.getElementById('channelName').value,
type: type,
config: config,
enabled: document.getElementById('channelEnabled').checked
};
try {
const response = await fetch(`/api/notifications/channels/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(channel)
});
if (response.ok) {
await loadChannels();
closeAddChannelModal();
showToast('Success', 'Channel updated successfully', 'success');
} else {
const error = await response.json();
showToast('Error', error.error || 'Failed to update channel', 'error');
}
} catch (error) {
console.error('Error updating channel:', error);
showToast('Error', 'Failed to update channel', 'error');
}
}
// Edit Rule
function editRule(id) {
const rule = rules.find(r => r.id === id);
if (!rule) return;
currentRuleMode = 'edit';
currentRuleId = id;
// Populate form with existing data
document.getElementById('ruleName').value = rule.name;
document.getElementById('ruleEnabled').checked = rule.enabled;
// Check event types
document.querySelectorAll('input[name="eventTypes"]').forEach(cb => {
cb.checked = rule.event_types.includes(cb.value);
});
// Set patterns and thresholds
document.getElementById('ruleHost').value = rule.host_id || '';
document.getElementById('ruleContainerPattern').value = rule.container_pattern || '';
document.getElementById('ruleImagePattern').value = rule.image_pattern || '';
document.getElementById('ruleCPUThreshold').value = rule.cpu_threshold || '';
document.getElementById('ruleMemoryThreshold').value = rule.memory_threshold || '';
document.getElementById('ruleThresholdDuration').value = rule.threshold_duration_seconds || 120;
document.getElementById('ruleCooldown').value = rule.cooldown_seconds || 300;
// Select channels
const channelSelect = document.getElementById('ruleChannels');
Array.from(channelSelect.options).forEach(opt => {
opt.selected = rule.channel_ids.includes(parseInt(opt.value));
});
// Change modal title
document.querySelector('#addRuleModal .modal-header h3').textContent = 'Edit Rule';
document.querySelector('#addRuleModal .btn-primary').textContent = 'Update Rule';
document.getElementById('addRuleModal').classList.add('show');
}
async function handleUpdateRule(id) {
const eventTypes = Array.from(document.querySelectorAll('input[name="eventTypes"]:checked'))
.map(cb => cb.value);
if (eventTypes.length === 0) {
showToast('Error', 'Please select at least one event type', 'error');
return;
}
const channelIds = Array.from(document.getElementById('ruleChannels').selectedOptions)
.map(opt => parseInt(opt.value));
if (channelIds.length === 0) {
showToast('Error', 'Please select at least one channel', 'error');
return;
}
const rule = {
id: id,
name: document.getElementById('ruleName').value,
enabled: document.getElementById('ruleEnabled').checked,
event_types: eventTypes,
container_pattern: document.getElementById('ruleContainerPattern').value || '',
image_pattern: document.getElementById('ruleImagePattern').value || '',
threshold_duration_seconds: parseInt(document.getElementById('ruleThresholdDuration').value) || 120,
cooldown_seconds: parseInt(document.getElementById('ruleCooldown').value) || 300,
channel_ids: channelIds
};
const hostId = document.getElementById('ruleHost').value;
if (hostId) rule.host_id = parseInt(hostId);
const cpuThreshold = document.getElementById('ruleCPUThreshold').value;
if (cpuThreshold) rule.cpu_threshold = parseFloat(cpuThreshold);
const memThreshold = document.getElementById('ruleMemoryThreshold').value;
if (memThreshold) rule.memory_threshold = parseFloat(memThreshold);
try {
const response = await fetch(`/api/notifications/rules/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(rule)
});
if (response.ok) {
await loadRules();
closeAddRuleModal();
showToast('Success', 'Rule updated successfully', 'success');
} else {
const error = await response.json();
showToast('Error', error.error || 'Failed to update rule', 'error');
}
} catch (error) {
console.error('Error updating rule:', error);
showToast('Error', 'Failed to update rule', 'error');
}
}
// Utility functions
function getEventTypeIcon(type) {
const icons = {
new_image: '🖼️',
state_change: '🔄',
container_started: '▶️',
container_stopped: '⏹️',
container_paused: '⏸️',
container_resumed: '▶️',
high_cpu: '📈',
high_memory: '💾',
anomalous_behavior: '⚠️'
};
return icons[type] || '📬';
}
function getEventTypeName(type) {
const names = {
new_image: 'New Image',
state_change: 'State Change',
container_started: 'Started',
container_stopped: 'Stopped',
container_paused: 'Paused',
container_resumed: 'Resumed',
high_cpu: 'High CPU',
high_memory: 'High Memory',
anomalous_behavior: 'Anomaly'
};
return names[type] || type;
}
function formatTimeAgo(timestamp) {
if (!timestamp) return 'Unknown';
const now = new Date();
const time = new Date(timestamp);
if (isNaN(time.getTime())) {
console.error('Invalid timestamp:', timestamp);
return 'Invalid date';
}
const diff = Math.floor((now - time) / 1000);
if (isNaN(diff)) {
console.error('Invalid diff calculation:', { now, time, timestamp });
return 'Unknown';
}
if (diff < 0) return 'Just now';
if (diff < 60) return `${diff}s ago`;
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`;
const days = Math.floor(diff / 86400);
if (isNaN(days)) return 'Unknown';
return `${days}d ago`;
}
function formatTimestamp(timestamp) {
if (!timestamp) return 'Unknown';
const date = new Date(timestamp);
if (isNaN(date.getTime())) return 'Invalid date';
return date.toLocaleString();
}
// Make functions globally available
window.initNotifications = initNotifications;
window.markNotificationRead = markNotificationRead;
window.markAllNotificationsRead = markAllNotificationsRead;
window.clearAllNotifications = clearAllNotifications;
window.testChannelById = testChannelById;
window.deleteChannel = deleteChannel;
window.editChannel = editChannel;
window.toggleRule = toggleRule;
window.deleteRule = deleteRule;
window.editRule = editRule;
window.deleteSilence = deleteSilence;
window.closeAddChannelModal = closeAddChannelModal;
window.closeAddRuleModal = closeAddRuleModal;
window.closeAddSilenceModal = closeAddSilenceModal;
window.testChannel = async () => {
// Test current channel in form
showToast('Info', 'Please save the channel first, then use the Test button', 'info');
};