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>
This commit is contained in:
Self Hosters
2025-12-19 14:05:18 -05:00
parent 003261b691
commit 80cc2883f0
9 changed files with 312 additions and 8 deletions

View File

@@ -30,8 +30,8 @@ func (s *Server) handleCreateNotificationChannel(w http.ResponseWriter, r *http.
} }
// Validate channel type // Validate channel type
if channel.Type != models.ChannelTypeWebhook && channel.Type != models.ChannelTypeNtfy && channel.Type != models.ChannelTypeInApp { if channel.Type != models.ChannelTypeWebhook && channel.Type != models.ChannelTypeNtfy && channel.Type != models.ChannelTypeInApp && channel.Type != models.ChannelTypeDiscord {
respondError(w, http.StatusBadRequest, "Invalid channel type. Must be: webhook, ntfy, or in_app") respondError(w, http.StatusBadRequest, "Invalid channel type. Must be: webhook, ntfy, in_app, or discord")
return return
} }
@@ -46,6 +46,11 @@ func (s *Server) handleCreateNotificationChannel(w http.ResponseWriter, r *http.
respondError(w, http.StatusBadRequest, "Ntfy channel requires 'topic' in config") respondError(w, http.StatusBadRequest, "Ntfy channel requires 'topic' in config")
return return
} }
} else if channel.Type == models.ChannelTypeDiscord {
if webhookURL, ok := channel.Config["webhook_url"].(string); !ok || webhookURL == "" {
respondError(w, http.StatusBadRequest, "Discord channel requires 'webhook_url' in config")
return
}
} }
if err := s.db.SaveNotificationChannel(&channel); err != nil { if err := s.db.SaveNotificationChannel(&channel); err != nil {

View File

@@ -446,6 +446,7 @@ const (
ChannelTypeWebhook = "webhook" ChannelTypeWebhook = "webhook"
ChannelTypeNtfy = "ntfy" ChannelTypeNtfy = "ntfy"
ChannelTypeInApp = "in_app" ChannelTypeInApp = "in_app"
ChannelTypeDiscord = "discord"
) )
// NotificationChannel represents a notification delivery channel // NotificationChannel represents a notification delivery channel
@@ -472,6 +473,11 @@ type NtfyConfig struct {
Topic string `json:"topic"` Topic string `json:"topic"`
} }
// DiscordConfig represents Discord webhook-specific configuration
type DiscordConfig struct {
WebhookURL string `json:"webhook_url"`
}
// NotificationRule represents a rule that triggers notifications // NotificationRule represents a rule that triggers notifications
type NotificationRule struct { type NotificationRule struct {
ID int64 `json:"id"` ID int64 `json:"id"`

View File

@@ -0,0 +1,252 @@
package channels
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/selfhosters-cc/container-census/internal/models"
)
// Discord embed colors by event type
const (
ColorRed = 16711680 // 0xFF0000 - container_stopped
ColorGreen = 65280 // 0x00FF00 - container_started
ColorBlue = 3447003 // 0x3498DB - new_image
ColorOrange = 16744448 // 0xFF8C00 - high_cpu, high_memory
ColorPurple = 9043969 // 0x8A00FF - anomalous_behavior
ColorGray = 9807270 // 0x959B9B - state_change, default
)
// DiscordChannel implements Discord webhook notifications
type DiscordChannel struct {
name string
config models.DiscordConfig
client *http.Client
}
// NewDiscordChannel creates a new Discord channel
func NewDiscordChannel(ch *models.NotificationChannel) (*DiscordChannel, error) {
// Parse config
configJSON, err := json.Marshal(ch.Config)
if err != nil {
return nil, fmt.Errorf("failed to marshal config: %w", err)
}
var discordConfig models.DiscordConfig
if err := json.Unmarshal(configJSON, &discordConfig); err != nil {
return nil, fmt.Errorf("failed to parse discord config: %w", err)
}
if discordConfig.WebhookURL == "" {
return nil, fmt.Errorf("discord webhook URL is required")
}
return &DiscordChannel{
name: ch.Name,
config: discordConfig,
client: &http.Client{
Timeout: 10 * time.Second,
},
}, nil
}
// getColorForEvent returns the appropriate Discord embed color for an event type
func getColorForEvent(eventType string) int {
switch eventType {
case models.EventTypeContainerStopped:
return ColorRed
case models.EventTypeContainerStarted, models.EventTypeContainerResumed:
return ColorGreen
case models.EventTypeNewImage:
return ColorBlue
case models.EventTypeHighCPU, models.EventTypeHighMemory:
return ColorOrange
case models.EventTypeAnomalousBehavior:
return ColorPurple
default:
return ColorGray
}
}
// getTitleForEvent returns a human-readable title for an event type
func getTitleForEvent(eventType string) string {
switch eventType {
case models.EventTypeContainerStopped:
return "Container Stopped"
case models.EventTypeContainerStarted:
return "Container Started"
case models.EventTypeContainerPaused:
return "Container Paused"
case models.EventTypeContainerResumed:
return "Container Resumed"
case models.EventTypeNewImage:
return "Image Updated"
case models.EventTypeHighCPU:
return "High CPU Usage"
case models.EventTypeHighMemory:
return "High Memory Usage"
case models.EventTypeAnomalousBehavior:
return "Anomalous Behavior Detected"
case models.EventTypeStateChange:
return "State Changed"
case "test":
return "Test Notification"
default:
return "Container Event"
}
}
// Send sends a Discord notification with a rich embed
func (dc *DiscordChannel) Send(ctx context.Context, message string, event models.NotificationEvent) error {
// Build embed fields
fields := []map[string]interface{}{}
if event.ContainerName != "" {
fields = append(fields, map[string]interface{}{
"name": "Container",
"value": event.ContainerName,
"inline": true,
})
}
if event.HostName != "" {
fields = append(fields, map[string]interface{}{
"name": "Host",
"value": event.HostName,
"inline": true,
})
}
if event.Image != "" {
fields = append(fields, map[string]interface{}{
"name": "Image",
"value": event.Image,
"inline": true,
})
}
// Add state change info
if event.OldState != "" && event.NewState != "" {
fields = append(fields, map[string]interface{}{
"name": "State Change",
"value": fmt.Sprintf("%s → %s", event.OldState, event.NewState),
"inline": true,
})
}
// Add image change info
if event.OldImage != "" && event.NewImage != "" {
fields = append(fields, map[string]interface{}{
"name": "Image Change",
"value": fmt.Sprintf("%s → %s", event.OldImage, event.NewImage),
"inline": false,
})
}
// Add resource usage info
if event.CPUPercent > 0 {
fields = append(fields, map[string]interface{}{
"name": "CPU Usage",
"value": fmt.Sprintf("%.1f%%", event.CPUPercent),
"inline": true,
})
}
if event.MemoryPercent > 0 {
fields = append(fields, map[string]interface{}{
"name": "Memory Usage",
"value": fmt.Sprintf("%.1f%%", event.MemoryPercent),
"inline": true,
})
}
// Build the embed
embed := map[string]interface{}{
"title": getTitleForEvent(event.EventType),
"description": message,
"color": getColorForEvent(event.EventType),
"timestamp": event.Timestamp.Format(time.RFC3339),
"footer": map[string]interface{}{
"text": "Container Census",
},
}
if len(fields) > 0 {
embed["fields"] = fields
}
// Build the payload
payload := map[string]interface{}{
"embeds": []map[string]interface{}{embed},
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal payload: %w", err)
}
// Create request
req, err := http.NewRequestWithContext(ctx, "POST", dc.config.WebhookURL, bytes.NewReader(payloadBytes))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Container-Census-Notifier/1.0")
// Send with retry logic (3 attempts)
var lastErr error
for attempt := 1; attempt <= 3; attempt++ {
resp, err := dc.client.Do(req)
if err != nil {
lastErr = fmt.Errorf("attempt %d failed: %w", attempt, err)
time.Sleep(time.Duration(attempt) * time.Second)
// Recreate request for retry (body was consumed)
req, _ = http.NewRequestWithContext(ctx, "POST", dc.config.WebhookURL, bytes.NewReader(payloadBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Container-Census-Notifier/1.0")
continue
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil // Success
}
lastErr = fmt.Errorf("attempt %d: HTTP %d", attempt, resp.StatusCode)
time.Sleep(time.Duration(attempt) * time.Second)
// Recreate request for retry
req, _ = http.NewRequestWithContext(ctx, "POST", dc.config.WebhookURL, bytes.NewReader(payloadBytes))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "Container-Census-Notifier/1.0")
}
return fmt.Errorf("discord webhook failed after 3 attempts: %w", lastErr)
}
// Test sends a test notification
func (dc *DiscordChannel) Test(ctx context.Context) error {
testEvent := models.NotificationEvent{
EventType: "test",
Timestamp: time.Now(),
ContainerName: "test-container",
HostName: "test-host",
Image: "test-image:latest",
}
return dc.Send(ctx, "Test notification from Container Census", testEvent)
}
// Type returns the channel type
func (dc *DiscordChannel) Type() string {
return models.ChannelTypeDiscord
}
// Name returns the channel name
func (dc *DiscordChannel) Name() string {
return dc.name
}

View File

@@ -652,6 +652,8 @@ func (ns *NotificationService) createChannelInstance(ch *models.NotificationChan
return channels.NewNtfyChannel(ch) return channels.NewNtfyChannel(ch)
case models.ChannelTypeInApp: case models.ChannelTypeInApp:
return channels.NewInAppChannel(ch, ns.db) return channels.NewInAppChannel(ch, ns.db)
case models.ChannelTypeDiscord:
return channels.NewDiscordChannel(ch)
default: default:
return nil, fmt.Errorf("unknown channel type: %s", ch.Type) return nil, fmt.Errorf("unknown channel type: %s", ch.Type)
} }

View File

@@ -109,7 +109,7 @@ interface ChannelFormModalProps {
function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFormModalProps) { function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFormModalProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [type, setType] = useState<'webhook' | 'ntfy' | 'in_app'>('webhook'); const [type, setType] = useState<'webhook' | 'ntfy' | 'in_app' | 'discord'>('webhook');
const [enabled, setEnabled] = useState(true); const [enabled, setEnabled] = useState(true);
const [config, setConfig] = useState<Record<string, string>>({}); const [config, setConfig] = useState<Record<string, string>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -117,7 +117,7 @@ function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFor
useEffect(() => { useEffect(() => {
if (editChannel) { if (editChannel) {
setName(editChannel.name); setName(editChannel.name);
setType(editChannel.type); setType(editChannel.type as 'webhook' | 'ntfy' | 'in_app' | 'discord');
setEnabled(editChannel.enabled); setEnabled(editChannel.enabled);
setConfig((editChannel.config || {}) as Record<string, string>); setConfig((editChannel.config || {}) as Record<string, string>);
} else { } else {
@@ -163,10 +163,11 @@ function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFor
<label className="block text-sm text-[var(--text-tertiary)] mb-1">Type</label> <label className="block text-sm text-[var(--text-tertiary)] mb-1">Type</label>
<select <select
value={type} value={type}
onChange={(e) => setType(e.target.value as 'webhook' | 'ntfy' | 'in_app')} onChange={(e) => setType(e.target.value as 'webhook' | 'ntfy' | 'in_app' | 'discord')}
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2" className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2"
> >
<option value="webhook">Webhook</option> <option value="webhook">Webhook</option>
<option value="discord">Discord</option>
<option value="ntfy">Ntfy</option> <option value="ntfy">Ntfy</option>
<option value="in_app">In-App</option> <option value="in_app">In-App</option>
</select> </select>
@@ -185,6 +186,23 @@ function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFor
</div> </div>
)} )}
{type === 'discord' && (
<div>
<label className="block text-sm text-[var(--text-tertiary)] mb-1">Discord Webhook URL</label>
<input
type="url"
value={config.webhook_url || ''}
onChange={(e) => setConfig({ ...config, webhook_url: e.target.value })}
required
placeholder="https://discord.com/api/webhooks/..."
className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-3 py-2"
/>
<p className="text-xs text-[var(--text-tertiary)] mt-1">
Create a webhook in Discord: Server Settings Integrations Webhooks
</p>
</div>
)}
{type === 'ntfy' && ( {type === 'ntfy' && (
<> <>
<div> <div>

View File

@@ -119,8 +119,10 @@ export const updateNotificationChannel = (id: number, channel: Partial<import('@
fetchApi<import('@/types').NotificationChannel>(`/notifications/channels/${id}`, { method: 'PUT', body: JSON.stringify(channel) }); fetchApi<import('@/types').NotificationChannel>(`/notifications/channels/${id}`, { method: 'PUT', body: JSON.stringify(channel) });
export const deleteNotificationChannel = (id: number) => export const deleteNotificationChannel = (id: number) =>
fetchApi<void>(`/notifications/channels/${id}`, { method: 'DELETE' }); fetchApi<void>(`/notifications/channels/${id}`, { method: 'DELETE' });
export const testNotificationChannel = (id: number) => export const testNotificationChannel = async (id: number): Promise<{ success: boolean; error?: string }> => {
fetchApi<{ success: boolean; error?: string }>(`/notifications/channels/${id}/test`, { method: 'POST' }); const result = await fetchApi<{ status: string; message: string }>(`/notifications/channels/${id}/test`, { method: 'POST' });
return { success: result.status === 'success', error: result.status !== 'success' ? result.message : undefined };
};
export const getNotificationRules = () => export const getNotificationRules = () =>
fetchApi<import('@/types').NotificationRule[]>('/notifications/rules'); fetchApi<import('@/types').NotificationRule[]>('/notifications/rules');

View File

@@ -138,7 +138,7 @@ export interface VulnerabilitySummary {
export interface NotificationChannel { export interface NotificationChannel {
id: number; id: number;
name: string; name: string;
type: 'webhook' | 'ntfy' | 'in_app'; type: 'webhook' | 'ntfy' | 'in_app' | 'discord';
enabled: boolean; enabled: boolean;
config: Record<string, unknown>; config: Record<string, unknown>;
created_at: string; created_at: string;

View File

@@ -1370,6 +1370,7 @@
<select id="channelType" required> <select id="channelType" required>
<option value="">Select type...</option> <option value="">Select type...</option>
<option value="webhook">Webhook</option> <option value="webhook">Webhook</option>
<option value="discord">Discord</option>
<option value="ntfy">Ntfy</option> <option value="ntfy">Ntfy</option>
<option value="in_app">In-App (Default)</option> <option value="in_app">In-App (Default)</option>
</select> </select>
@@ -1384,6 +1385,13 @@
<textarea id="webhookHeaders" placeholder='{"Authorization": "Bearer token"}'></textarea> <textarea id="webhookHeaders" placeholder='{"Authorization": "Bearer token"}'></textarea>
</div> </div>
</div> </div>
<div id="discordConfig" class="channel-config" style="display: none;">
<div class="form-group">
<label for="discordWebhookURL">Discord Webhook URL *</label>
<input type="url" id="discordWebhookURL" placeholder="https://discord.com/api/webhooks/...">
<small class="form-help">Create a webhook in Discord: Server Settings → Integrations → Webhooks</small>
</div>
</div>
<div id="ntfyConfig" class="channel-config" style="display: none;"> <div id="ntfyConfig" class="channel-config" style="display: none;">
<div class="form-group"> <div class="form-group">
<label for="ntfyServerURL">Ntfy Server URL</label> <label for="ntfyServerURL">Ntfy Server URL</label>

View File

@@ -355,6 +355,10 @@ function renderChannelDetails(channel) {
<div class="channel-detail"><span class="detail-label">URL:</span> <span class="detail-value">${config.url || 'N/A'}</span></div> <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>` : ''} ${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': case 'ntfy':
return ` 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">Server:</span> <span class="detail-value">${config.server_url || 'https://ntfy.sh'}</span></div>
@@ -539,6 +543,7 @@ function closeAddChannelModal() {
function updateChannelConfigFields() { function updateChannelConfigFields() {
const type = document.getElementById('channelType').value; const type = document.getElementById('channelType').value;
document.getElementById('webhookConfig').style.display = type === 'webhook' ? 'block' : 'none'; 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'; document.getElementById('ntfyConfig').style.display = type === 'ntfy' ? 'block' : 'none';
} }
@@ -567,6 +572,8 @@ async function handleAddChannel() {
return; return;
} }
} }
} else if (type === 'discord') {
config.webhook_url = document.getElementById('discordWebhookURL').value;
} else if (type === 'ntfy') { } else if (type === 'ntfy') {
config.server_url = document.getElementById('ntfyServerURL').value || 'https://ntfy.sh'; config.server_url = document.getElementById('ntfyServerURL').value || 'https://ntfy.sh';
config.topic = document.getElementById('ntfyTopic').value; config.topic = document.getElementById('ntfyTopic').value;
@@ -892,6 +899,8 @@ function editChannel(id) {
if (channel.config.headers) { if (channel.config.headers) {
document.getElementById('webhookHeaders').value = JSON.stringify(channel.config.headers, null, 2); 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') { } else if (channel.type === 'ntfy') {
document.getElementById('ntfyServerURL').value = channel.config.server_url || 'https://ntfy.sh'; document.getElementById('ntfyServerURL').value = channel.config.server_url || 'https://ntfy.sh';
document.getElementById('ntfyTopic').value = channel.config.topic || ''; document.getElementById('ntfyTopic').value = channel.config.topic || '';
@@ -920,6 +929,8 @@ async function handleUpdateChannel(id) {
return; return;
} }
} }
} else if (type === 'discord') {
config.webhook_url = document.getElementById('discordWebhookURL').value;
} else if (type === 'ntfy') { } else if (type === 'ntfy') {
config.server_url = document.getElementById('ntfyServerURL').value || 'https://ntfy.sh'; config.server_url = document.getElementById('ntfyServerURL').value || 'https://ntfy.sh';
config.topic = document.getElementById('ntfyTopic').value; config.topic = document.getElementById('ntfyTopic').value;