mirror of
https://github.com/selfhosters-cc/container-census.git
synced 2025-12-21 05:59:44 -06:00
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:
@@ -30,8 +30,8 @@ func (s *Server) handleCreateNotificationChannel(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
// Validate channel type
|
||||
if channel.Type != models.ChannelTypeWebhook && channel.Type != models.ChannelTypeNtfy && channel.Type != models.ChannelTypeInApp {
|
||||
respondError(w, http.StatusBadRequest, "Invalid channel type. Must be: webhook, ntfy, or in_app")
|
||||
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, in_app, or discord")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ func (s *Server) handleCreateNotificationChannel(w http.ResponseWriter, r *http.
|
||||
respondError(w, http.StatusBadRequest, "Ntfy channel requires 'topic' in config")
|
||||
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 {
|
||||
|
||||
@@ -446,6 +446,7 @@ const (
|
||||
ChannelTypeWebhook = "webhook"
|
||||
ChannelTypeNtfy = "ntfy"
|
||||
ChannelTypeInApp = "in_app"
|
||||
ChannelTypeDiscord = "discord"
|
||||
)
|
||||
|
||||
// NotificationChannel represents a notification delivery channel
|
||||
@@ -472,6 +473,11 @@ type NtfyConfig struct {
|
||||
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
|
||||
type NotificationRule struct {
|
||||
ID int64 `json:"id"`
|
||||
|
||||
252
internal/notifications/channels/discord.go
Normal file
252
internal/notifications/channels/discord.go
Normal 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
|
||||
}
|
||||
@@ -652,6 +652,8 @@ func (ns *NotificationService) createChannelInstance(ch *models.NotificationChan
|
||||
return channels.NewNtfyChannel(ch)
|
||||
case models.ChannelTypeInApp:
|
||||
return channels.NewInAppChannel(ch, ns.db)
|
||||
case models.ChannelTypeDiscord:
|
||||
return channels.NewDiscordChannel(ch)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown channel type: %s", ch.Type)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ interface ChannelFormModalProps {
|
||||
|
||||
function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFormModalProps) {
|
||||
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 [config, setConfig] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -117,7 +117,7 @@ function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFor
|
||||
useEffect(() => {
|
||||
if (editChannel) {
|
||||
setName(editChannel.name);
|
||||
setType(editChannel.type);
|
||||
setType(editChannel.type as 'webhook' | 'ntfy' | 'in_app' | 'discord');
|
||||
setEnabled(editChannel.enabled);
|
||||
setConfig((editChannel.config || {}) as Record<string, string>);
|
||||
} 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>
|
||||
<select
|
||||
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"
|
||||
>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="ntfy">Ntfy</option>
|
||||
<option value="in_app">In-App</option>
|
||||
</select>
|
||||
@@ -185,6 +186,23 @@ function ChannelFormModal({ isOpen, onClose, onSubmit, editChannel }: ChannelFor
|
||||
</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' && (
|
||||
<>
|
||||
<div>
|
||||
|
||||
@@ -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) });
|
||||
export const deleteNotificationChannel = (id: number) =>
|
||||
fetchApi<void>(`/notifications/channels/${id}`, { method: 'DELETE' });
|
||||
export const testNotificationChannel = (id: number) =>
|
||||
fetchApi<{ success: boolean; error?: string }>(`/notifications/channels/${id}/test`, { method: 'POST' });
|
||||
export const testNotificationChannel = async (id: number): Promise<{ success: boolean; error?: string }> => {
|
||||
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 = () =>
|
||||
fetchApi<import('@/types').NotificationRule[]>('/notifications/rules');
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface VulnerabilitySummary {
|
||||
export interface NotificationChannel {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'webhook' | 'ntfy' | 'in_app';
|
||||
type: 'webhook' | 'ntfy' | 'in_app' | 'discord';
|
||||
enabled: boolean;
|
||||
config: Record<string, unknown>;
|
||||
created_at: string;
|
||||
|
||||
@@ -1370,6 +1370,7 @@
|
||||
<select id="channelType" required>
|
||||
<option value="">Select type...</option>
|
||||
<option value="webhook">Webhook</option>
|
||||
<option value="discord">Discord</option>
|
||||
<option value="ntfy">Ntfy</option>
|
||||
<option value="in_app">In-App (Default)</option>
|
||||
</select>
|
||||
@@ -1384,6 +1385,13 @@
|
||||
<textarea id="webhookHeaders" placeholder='{"Authorization": "Bearer token"}'></textarea>
|
||||
</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 class="form-group">
|
||||
<label for="ntfyServerURL">Ntfy Server URL</label>
|
||||
|
||||
@@ -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>
|
||||
${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>
|
||||
@@ -539,6 +543,7 @@ function closeAddChannelModal() {
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -567,6 +572,8 @@ async function handleAddChannel() {
|
||||
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;
|
||||
@@ -892,6 +899,8 @@ function editChannel(id) {
|
||||
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 || '';
|
||||
@@ -920,6 +929,8 @@ async function handleUpdateChannel(id) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user