mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-02-18 00:17:39 +01:00
@@ -72,7 +72,7 @@ export interface AppriseConfig {
|
||||
|
||||
export interface NotificationTestRequest {
|
||||
type: 'email' | 'webhook' | 'apprise';
|
||||
config?: Record<string, unknown>; // Backend expects different format than frontend types
|
||||
config?: Record<string, unknown> | AppriseConfig; // Backend expects different format than frontend types
|
||||
webhookId?: string;
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export class NotificationsAPI {
|
||||
static async testNotification(
|
||||
request: NotificationTestRequest,
|
||||
): Promise<{ success: boolean; message?: string }> {
|
||||
const body: { method: string; config?: Record<string, unknown>; webhookId?: string } = {
|
||||
const body: { method: string; config?: Record<string, unknown> | AppriseConfig; webhookId?: string } = {
|
||||
method: request.type,
|
||||
};
|
||||
|
||||
|
||||
@@ -2650,11 +2650,29 @@ interface DestinationsTabProps {
|
||||
function DestinationsTab(props: DestinationsTabProps) {
|
||||
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
|
||||
const [testingEmail, setTestingEmail] = createSignal(false);
|
||||
const [testingApprise, setTestingApprise] = createSignal(false);
|
||||
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
|
||||
const appriseState = () => props.appriseConfig();
|
||||
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
|
||||
props.setAppriseConfig({ ...props.appriseConfig(), ...partial });
|
||||
};
|
||||
const buildAppriseRequestConfig = (): AppriseConfig => {
|
||||
const config = appriseState();
|
||||
const serverUrl = (config.serverUrl || '').trim();
|
||||
const apiKeyHeader = (config.apiKeyHeader || '').trim() || 'X-API-KEY';
|
||||
return {
|
||||
enabled: config.enabled,
|
||||
mode: config.mode,
|
||||
targets: parseAppriseTargets(config.targetsText),
|
||||
cliPath: config.cliPath?.trim() || 'apprise',
|
||||
timeoutSeconds: config.timeoutSeconds,
|
||||
serverUrl,
|
||||
configKey: config.configKey.trim(),
|
||||
apiKey: config.apiKey,
|
||||
apiKeyHeader,
|
||||
skipTlsVerify: config.skipTlsVerify,
|
||||
};
|
||||
};
|
||||
// Load webhooks on mount (email config is now loaded in parent)
|
||||
onMount(async () => {
|
||||
try {
|
||||
@@ -2689,6 +2707,39 @@ function DestinationsTab(props: DestinationsTabProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const testApprise = async () => {
|
||||
setTestingApprise(true);
|
||||
try {
|
||||
const config = buildAppriseRequestConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
throw new Error('Enable Apprise notifications before sending a test.');
|
||||
}
|
||||
|
||||
const targets = config.targets || [];
|
||||
if (config.mode === 'cli' && targets.length === 0) {
|
||||
throw new Error('Add at least one Apprise target to test CLI delivery.');
|
||||
}
|
||||
if (config.mode === 'http' && !config.serverUrl) {
|
||||
throw new Error('Enter an Apprise API server URL to test API delivery.');
|
||||
}
|
||||
|
||||
await NotificationsAPI.testNotification({
|
||||
type: 'apprise',
|
||||
config,
|
||||
});
|
||||
showSuccess('Test Apprise notification sent successfully!');
|
||||
} catch (err) {
|
||||
logger.error('Failed to send test Apprise notification:', err);
|
||||
showError(
|
||||
'Failed to send test Apprise notification',
|
||||
err instanceof Error ? err.message : 'Unknown error',
|
||||
);
|
||||
} finally {
|
||||
setTestingApprise(false);
|
||||
}
|
||||
};
|
||||
|
||||
const testWebhook = async (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => {
|
||||
setTestingWebhook(webhookId);
|
||||
try {
|
||||
@@ -2750,21 +2801,30 @@ function DestinationsTab(props: DestinationsTabProps) {
|
||||
|
||||
<SettingsPanel
|
||||
title="Apprise notifications"
|
||||
description="Relay grouped alerts through the Apprise CLI."
|
||||
description="Relay grouped alerts through Apprise via CLI or remote API."
|
||||
action={
|
||||
<Toggle
|
||||
checked={appriseState().enabled}
|
||||
onChange={(e) => {
|
||||
updateApprise({ enabled: e.currentTarget.checked });
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
containerClass="sm:self-start"
|
||||
label={
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{appriseState().enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<div class="flex items-center gap-3 sm:self-start">
|
||||
<Toggle
|
||||
checked={appriseState().enabled}
|
||||
onChange={(e) => {
|
||||
updateApprise({ enabled: e.currentTarget.checked });
|
||||
props.setHasUnsavedChanges(true);
|
||||
}}
|
||||
containerClass=""
|
||||
label={
|
||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{appriseState().enabled ? 'Enabled' : 'Disabled'}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<button
|
||||
class="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||
disabled={!appriseState().enabled || testingApprise()}
|
||||
onClick={testApprise}
|
||||
>
|
||||
{testingApprise() ? 'Testing...' : 'Send test'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
class="min-w-0"
|
||||
bodyClass="space-y-4"
|
||||
|
||||
@@ -330,10 +330,10 @@ func (h *NotificationHandlers) TestNotification(w http.ResponseWriter, r *http.R
|
||||
Msg("Test notification request received")
|
||||
|
||||
var req struct {
|
||||
Method string `json:"method"` // "email" or "webhook"
|
||||
Type string `json:"type"` // Alternative field name used by frontend
|
||||
Config *notifications.EmailConfig `json:"config,omitempty"` // Optional config for testing
|
||||
WebhookID string `json:"webhookId,omitempty"` // For webhook testing
|
||||
Method string `json:"method"` // "email", "webhook", or "apprise"
|
||||
Type string `json:"type"` // Alternative field name used by frontend
|
||||
Config json.RawMessage `json:"config,omitempty"` // Optional config for testing (email or apprise)
|
||||
WebhookID string `json:"webhookId,omitempty"` // For webhook testing
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
@@ -387,24 +387,40 @@ func (h *NotificationHandlers) TestNotification(w http.ResponseWriter, r *http.R
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else if req.Method == "email" && req.Config != nil {
|
||||
// If config is provided, use it for testing (without saving)
|
||||
} else if req.Method == "email" && len(req.Config) > 0 {
|
||||
var emailConfig notifications.EmailConfig
|
||||
if err := json.Unmarshal(req.Config, &emailConfig); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid email config: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// If password is empty, use the saved password
|
||||
if req.Config.Password == "" {
|
||||
if emailConfig.Password == "" {
|
||||
savedConfig := h.monitor.GetNotificationManager().GetEmailConfig()
|
||||
req.Config.Password = savedConfig.Password
|
||||
emailConfig.Password = savedConfig.Password
|
||||
}
|
||||
|
||||
log.Info().
|
||||
Bool("enabled", req.Config.Enabled).
|
||||
Str("smtp", req.Config.SMTPHost).
|
||||
Str("from", req.Config.From).
|
||||
Int("toCount", len(req.Config.To)).
|
||||
Strs("to", req.Config.To).
|
||||
Bool("hasPassword", req.Config.Password != "").
|
||||
Bool("enabled", emailConfig.Enabled).
|
||||
Str("smtp", emailConfig.SMTPHost).
|
||||
Str("from", emailConfig.From).
|
||||
Int("toCount", len(emailConfig.To)).
|
||||
Strs("to", emailConfig.To).
|
||||
Bool("hasPassword", emailConfig.Password != "").
|
||||
Msg("Testing email with provided config")
|
||||
|
||||
if err := h.monitor.GetNotificationManager().SendTestNotificationWithConfig(req.Method, req.Config, nodeInfo); err != nil {
|
||||
if err := h.monitor.GetNotificationManager().SendTestNotificationWithConfig(req.Method, &emailConfig, nodeInfo); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else if req.Method == "apprise" && len(req.Config) > 0 {
|
||||
var appriseConfig notifications.AppriseConfig
|
||||
if err := json.Unmarshal(req.Config, &appriseConfig); err != nil {
|
||||
http.Error(w, fmt.Sprintf("Invalid Apprise config: %v", err), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.monitor.GetNotificationManager().SendTestAppriseWithConfig(appriseConfig); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
@@ -631,10 +647,10 @@ func (h *NotificationHandlers) GetNotificationHealth(w http.ResponseWriter, r *h
|
||||
webhooks := nm.GetWebhooks()
|
||||
|
||||
health := map[string]interface{}{
|
||||
"queue": queueStats,
|
||||
"queue": queueStats,
|
||||
"email": map[string]interface{}{
|
||||
"enabled": emailCfg.Enabled,
|
||||
"configured": emailCfg.SMTPHost != "",
|
||||
"enabled": emailCfg.Enabled,
|
||||
"configured": emailCfg.SMTPHost != "",
|
||||
},
|
||||
"webhooks": map[string]interface{}{
|
||||
"total": len(webhooks),
|
||||
|
||||
@@ -105,29 +105,29 @@ type webhookRateLimit struct {
|
||||
|
||||
// NotificationManager handles sending notifications
|
||||
type NotificationManager struct {
|
||||
mu sync.RWMutex
|
||||
emailConfig EmailConfig
|
||||
emailManager *EnhancedEmailManager // Shared email manager for rate limiting
|
||||
webhooks []WebhookConfig
|
||||
appriseConfig AppriseConfig
|
||||
enabled bool
|
||||
cooldown time.Duration
|
||||
lastNotified map[string]notificationRecord
|
||||
groupWindow time.Duration
|
||||
pendingAlerts []*alerts.Alert
|
||||
groupTimer *time.Timer
|
||||
groupByNode bool
|
||||
publicURL string // Full URL to access Pulse
|
||||
groupByGuest bool
|
||||
webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging
|
||||
webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL
|
||||
webhookRateMu sync.Mutex // Separate mutex for webhook rate limiting
|
||||
appriseExec appriseExecFunc
|
||||
queue *NotificationQueue // Persistent notification queue
|
||||
webhookClient *http.Client // Shared HTTP client for webhooks
|
||||
stopCleanup chan struct{} // Signal to stop cleanup goroutine
|
||||
allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets
|
||||
allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets
|
||||
mu sync.RWMutex
|
||||
emailConfig EmailConfig
|
||||
emailManager *EnhancedEmailManager // Shared email manager for rate limiting
|
||||
webhooks []WebhookConfig
|
||||
appriseConfig AppriseConfig
|
||||
enabled bool
|
||||
cooldown time.Duration
|
||||
lastNotified map[string]notificationRecord
|
||||
groupWindow time.Duration
|
||||
pendingAlerts []*alerts.Alert
|
||||
groupTimer *time.Timer
|
||||
groupByNode bool
|
||||
publicURL string // Full URL to access Pulse
|
||||
groupByGuest bool
|
||||
webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging
|
||||
webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL
|
||||
webhookRateMu sync.Mutex // Separate mutex for webhook rate limiting
|
||||
appriseExec appriseExecFunc
|
||||
queue *NotificationQueue // Persistent notification queue
|
||||
webhookClient *http.Client // Shared HTTP client for webhooks
|
||||
stopCleanup chan struct{} // Signal to stop cleanup goroutine
|
||||
allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets
|
||||
allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets
|
||||
}
|
||||
|
||||
type appriseExecFunc func(ctx context.Context, path string, args []string) ([]byte, error)
|
||||
@@ -2066,14 +2066,14 @@ func (n *NotificationManager) ValidateWebhookURL(webhookURL string) error {
|
||||
func isPrivateIP(ip net.IP) bool {
|
||||
// Private IPv4 ranges
|
||||
privateRanges := []string{
|
||||
"10.0.0.0/8", // RFC1918
|
||||
"172.16.0.0/12", // RFC1918
|
||||
"192.168.0.0/16", // RFC1918
|
||||
"127.0.0.0/8", // Loopback
|
||||
"169.254.0.0/16", // Link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
"fe80::/10", // IPv6 link-local
|
||||
"fc00::/7", // IPv6 unique local
|
||||
"10.0.0.0/8", // RFC1918
|
||||
"172.16.0.0/12", // RFC1918
|
||||
"192.168.0.0/16", // RFC1918
|
||||
"127.0.0.0/8", // Loopback
|
||||
"169.254.0.0/16", // Link-local
|
||||
"::1/128", // IPv6 loopback
|
||||
"fe80::/10", // IPv6 link-local
|
||||
"fc00::/7", // IPv6 unique local
|
||||
}
|
||||
|
||||
for _, cidr := range privateRanges {
|
||||
@@ -2263,9 +2263,8 @@ func (n *NotificationManager) groupAlerts(alertList []*alerts.Alert) map[string]
|
||||
return groups
|
||||
}
|
||||
|
||||
// SendTestNotification sends a test notification
|
||||
func (n *NotificationManager) SendTestNotification(method string) error {
|
||||
testAlert := &alerts.Alert{
|
||||
func buildNotificationTestAlert() *alerts.Alert {
|
||||
return &alerts.Alert{
|
||||
ID: "test-alert",
|
||||
Type: "cpu",
|
||||
Level: "warning",
|
||||
@@ -2282,6 +2281,11 @@ func (n *NotificationManager) SendTestNotification(method string) error {
|
||||
"resourceType": "vm",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SendTestNotification sends a test notification
|
||||
func (n *NotificationManager) SendTestNotification(method string) error {
|
||||
testAlert := buildNotificationTestAlert()
|
||||
|
||||
switch method {
|
||||
case "email":
|
||||
@@ -2336,13 +2340,37 @@ func (n *NotificationManager) SendTestNotification(method string) error {
|
||||
}
|
||||
|
||||
// Use sendGroupedApprise with a single test alert
|
||||
n.sendGroupedApprise(appriseConfig, []*alerts.Alert{testAlert})
|
||||
return nil
|
||||
return n.sendGroupedApprise(appriseConfig, []*alerts.Alert{testAlert})
|
||||
default:
|
||||
return fmt.Errorf("unknown notification method: %s", method)
|
||||
}
|
||||
}
|
||||
|
||||
// SendTestAppriseWithConfig sends a test Apprise notification using provided config
|
||||
func (n *NotificationManager) SendTestAppriseWithConfig(config AppriseConfig) error {
|
||||
cfg := NormalizeAppriseConfig(config)
|
||||
|
||||
log.Info().
|
||||
Bool("enabled", cfg.Enabled).
|
||||
Str("mode", string(cfg.Mode)).
|
||||
Int("targetCount", len(cfg.Targets)).
|
||||
Str("serverURL", cfg.ServerURL).
|
||||
Msg("Testing Apprise notification with provided config")
|
||||
|
||||
if !cfg.Enabled {
|
||||
switch cfg.Mode {
|
||||
case AppriseModeCLI:
|
||||
return fmt.Errorf("apprise notifications are not enabled in the provided configuration: at least one target is required for CLI mode")
|
||||
case AppriseModeHTTP:
|
||||
return fmt.Errorf("apprise notifications are not enabled in the provided configuration: server URL is required for API mode")
|
||||
default:
|
||||
return fmt.Errorf("apprise notifications are not enabled in the provided configuration")
|
||||
}
|
||||
}
|
||||
|
||||
return n.sendGroupedApprise(cfg, []*alerts.Alert{buildNotificationTestAlert()})
|
||||
}
|
||||
|
||||
// SendTestWebhook sends a test notification to a specific webhook
|
||||
func (n *NotificationManager) SendTestWebhook(webhook WebhookConfig) error {
|
||||
// Create a test alert for webhook testing with realistic values
|
||||
|
||||
@@ -570,6 +570,48 @@ func TestSendTestNotificationApprise(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestAppriseWithConfig(t *testing.T) {
|
||||
nm := NewNotificationManager("")
|
||||
defer nm.Stop()
|
||||
|
||||
// Disabled config should fail
|
||||
err := nm.SendTestAppriseWithConfig(AppriseConfig{
|
||||
Enabled: false,
|
||||
Targets: []string{"discord://token"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not enabled") {
|
||||
t.Fatalf("expected not enabled error, got %v", err)
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
var cliPath string
|
||||
|
||||
nm.appriseExec = func(ctx context.Context, path string, args []string) ([]byte, error) {
|
||||
cliPath = path
|
||||
close(done)
|
||||
return []byte("ok"), nil
|
||||
}
|
||||
|
||||
err = nm.SendTestAppriseWithConfig(AppriseConfig{
|
||||
Enabled: true,
|
||||
Mode: AppriseModeCLI,
|
||||
Targets: []string{"discord://token"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for valid Apprise config, got %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for Apprise test execution")
|
||||
}
|
||||
|
||||
if cliPath != "apprise" {
|
||||
t.Fatalf("expected default CLI path 'apprise', got %q", cliPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendTestNotificationAppriseHTTP(t *testing.T) {
|
||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||
|
||||
|
||||
Reference in New Issue
Block a user