parent
35a145adc4
commit
d779328878
5 changed files with 216 additions and 70 deletions
|
|
@ -72,7 +72,7 @@ export interface AppriseConfig {
|
||||||
|
|
||||||
export interface NotificationTestRequest {
|
export interface NotificationTestRequest {
|
||||||
type: 'email' | 'webhook' | 'apprise';
|
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;
|
webhookId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@ export class NotificationsAPI {
|
||||||
static async testNotification(
|
static async testNotification(
|
||||||
request: NotificationTestRequest,
|
request: NotificationTestRequest,
|
||||||
): Promise<{ success: boolean; message?: string }> {
|
): 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,
|
method: request.type,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2650,11 +2650,29 @@ interface DestinationsTabProps {
|
||||||
function DestinationsTab(props: DestinationsTabProps) {
|
function DestinationsTab(props: DestinationsTabProps) {
|
||||||
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
|
const [webhooks, setWebhooks] = createSignal<Webhook[]>([]);
|
||||||
const [testingEmail, setTestingEmail] = createSignal(false);
|
const [testingEmail, setTestingEmail] = createSignal(false);
|
||||||
|
const [testingApprise, setTestingApprise] = createSignal(false);
|
||||||
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
|
const [testingWebhook, setTestingWebhook] = createSignal<string | null>(null);
|
||||||
const appriseState = () => props.appriseConfig();
|
const appriseState = () => props.appriseConfig();
|
||||||
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
|
const updateApprise = (partial: Partial<UIAppriseConfig>) => {
|
||||||
props.setAppriseConfig({ ...props.appriseConfig(), ...partial });
|
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)
|
// Load webhooks on mount (email config is now loaded in parent)
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
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'>) => {
|
const testWebhook = async (webhookId: string, webhookData?: Omit<Webhook, 'id'>) => {
|
||||||
setTestingWebhook(webhookId);
|
setTestingWebhook(webhookId);
|
||||||
try {
|
try {
|
||||||
|
|
@ -2750,21 +2801,30 @@ function DestinationsTab(props: DestinationsTabProps) {
|
||||||
|
|
||||||
<SettingsPanel
|
<SettingsPanel
|
||||||
title="Apprise notifications"
|
title="Apprise notifications"
|
||||||
description="Relay grouped alerts through the Apprise CLI."
|
description="Relay grouped alerts through Apprise via CLI or remote API."
|
||||||
action={
|
action={
|
||||||
<Toggle
|
<div class="flex items-center gap-3 sm:self-start">
|
||||||
checked={appriseState().enabled}
|
<Toggle
|
||||||
onChange={(e) => {
|
checked={appriseState().enabled}
|
||||||
updateApprise({ enabled: e.currentTarget.checked });
|
onChange={(e) => {
|
||||||
props.setHasUnsavedChanges(true);
|
updateApprise({ enabled: e.currentTarget.checked });
|
||||||
}}
|
props.setHasUnsavedChanges(true);
|
||||||
containerClass="sm:self-start"
|
}}
|
||||||
label={
|
containerClass=""
|
||||||
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
label={
|
||||||
{appriseState().enabled ? 'Enabled' : 'Disabled'}
|
<span class="text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||||
</span>
|
{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"
|
class="min-w-0"
|
||||||
bodyClass="space-y-4"
|
bodyClass="space-y-4"
|
||||||
|
|
|
||||||
|
|
@ -330,10 +330,10 @@ func (h *NotificationHandlers) TestNotification(w http.ResponseWriter, r *http.R
|
||||||
Msg("Test notification request received")
|
Msg("Test notification request received")
|
||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Method string `json:"method"` // "email" or "webhook"
|
Method string `json:"method"` // "email", "webhook", or "apprise"
|
||||||
Type string `json:"type"` // Alternative field name used by frontend
|
Type string `json:"type"` // Alternative field name used by frontend
|
||||||
Config *notifications.EmailConfig `json:"config,omitempty"` // Optional config for testing
|
Config json.RawMessage `json:"config,omitempty"` // Optional config for testing (email or apprise)
|
||||||
WebhookID string `json:"webhookId,omitempty"` // For webhook testing
|
WebhookID string `json:"webhookId,omitempty"` // For webhook testing
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal(body, &req); err != nil {
|
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)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if req.Method == "email" && req.Config != nil {
|
} else if req.Method == "email" && len(req.Config) > 0 {
|
||||||
// If config is provided, use it for testing (without saving)
|
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 password is empty, use the saved password
|
||||||
if req.Config.Password == "" {
|
if emailConfig.Password == "" {
|
||||||
savedConfig := h.monitor.GetNotificationManager().GetEmailConfig()
|
savedConfig := h.monitor.GetNotificationManager().GetEmailConfig()
|
||||||
req.Config.Password = savedConfig.Password
|
emailConfig.Password = savedConfig.Password
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info().
|
log.Info().
|
||||||
Bool("enabled", req.Config.Enabled).
|
Bool("enabled", emailConfig.Enabled).
|
||||||
Str("smtp", req.Config.SMTPHost).
|
Str("smtp", emailConfig.SMTPHost).
|
||||||
Str("from", req.Config.From).
|
Str("from", emailConfig.From).
|
||||||
Int("toCount", len(req.Config.To)).
|
Int("toCount", len(emailConfig.To)).
|
||||||
Strs("to", req.Config.To).
|
Strs("to", emailConfig.To).
|
||||||
Bool("hasPassword", req.Config.Password != "").
|
Bool("hasPassword", emailConfig.Password != "").
|
||||||
Msg("Testing email with provided config")
|
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)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -631,10 +647,10 @@ func (h *NotificationHandlers) GetNotificationHealth(w http.ResponseWriter, r *h
|
||||||
webhooks := nm.GetWebhooks()
|
webhooks := nm.GetWebhooks()
|
||||||
|
|
||||||
health := map[string]interface{}{
|
health := map[string]interface{}{
|
||||||
"queue": queueStats,
|
"queue": queueStats,
|
||||||
"email": map[string]interface{}{
|
"email": map[string]interface{}{
|
||||||
"enabled": emailCfg.Enabled,
|
"enabled": emailCfg.Enabled,
|
||||||
"configured": emailCfg.SMTPHost != "",
|
"configured": emailCfg.SMTPHost != "",
|
||||||
},
|
},
|
||||||
"webhooks": map[string]interface{}{
|
"webhooks": map[string]interface{}{
|
||||||
"total": len(webhooks),
|
"total": len(webhooks),
|
||||||
|
|
|
||||||
|
|
@ -105,29 +105,29 @@ type webhookRateLimit struct {
|
||||||
|
|
||||||
// NotificationManager handles sending notifications
|
// NotificationManager handles sending notifications
|
||||||
type NotificationManager struct {
|
type NotificationManager struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
emailConfig EmailConfig
|
emailConfig EmailConfig
|
||||||
emailManager *EnhancedEmailManager // Shared email manager for rate limiting
|
emailManager *EnhancedEmailManager // Shared email manager for rate limiting
|
||||||
webhooks []WebhookConfig
|
webhooks []WebhookConfig
|
||||||
appriseConfig AppriseConfig
|
appriseConfig AppriseConfig
|
||||||
enabled bool
|
enabled bool
|
||||||
cooldown time.Duration
|
cooldown time.Duration
|
||||||
lastNotified map[string]notificationRecord
|
lastNotified map[string]notificationRecord
|
||||||
groupWindow time.Duration
|
groupWindow time.Duration
|
||||||
pendingAlerts []*alerts.Alert
|
pendingAlerts []*alerts.Alert
|
||||||
groupTimer *time.Timer
|
groupTimer *time.Timer
|
||||||
groupByNode bool
|
groupByNode bool
|
||||||
publicURL string // Full URL to access Pulse
|
publicURL string // Full URL to access Pulse
|
||||||
groupByGuest bool
|
groupByGuest bool
|
||||||
webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging
|
webhookHistory []WebhookDelivery // Keep last 100 webhook deliveries for debugging
|
||||||
webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL
|
webhookRateLimits map[string]*webhookRateLimit // Track rate limits per webhook URL
|
||||||
webhookRateMu sync.Mutex // Separate mutex for webhook rate limiting
|
webhookRateMu sync.Mutex // Separate mutex for webhook rate limiting
|
||||||
appriseExec appriseExecFunc
|
appriseExec appriseExecFunc
|
||||||
queue *NotificationQueue // Persistent notification queue
|
queue *NotificationQueue // Persistent notification queue
|
||||||
webhookClient *http.Client // Shared HTTP client for webhooks
|
webhookClient *http.Client // Shared HTTP client for webhooks
|
||||||
stopCleanup chan struct{} // Signal to stop cleanup goroutine
|
stopCleanup chan struct{} // Signal to stop cleanup goroutine
|
||||||
allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets
|
allowedPrivateNets []*net.IPNet // Parsed CIDR ranges allowed for private webhook targets
|
||||||
allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets
|
allowedPrivateMu sync.RWMutex // Protects allowedPrivateNets
|
||||||
}
|
}
|
||||||
|
|
||||||
type appriseExecFunc func(ctx context.Context, path string, args []string) ([]byte, error)
|
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 {
|
func isPrivateIP(ip net.IP) bool {
|
||||||
// Private IPv4 ranges
|
// Private IPv4 ranges
|
||||||
privateRanges := []string{
|
privateRanges := []string{
|
||||||
"10.0.0.0/8", // RFC1918
|
"10.0.0.0/8", // RFC1918
|
||||||
"172.16.0.0/12", // RFC1918
|
"172.16.0.0/12", // RFC1918
|
||||||
"192.168.0.0/16", // RFC1918
|
"192.168.0.0/16", // RFC1918
|
||||||
"127.0.0.0/8", // Loopback
|
"127.0.0.0/8", // Loopback
|
||||||
"169.254.0.0/16", // Link-local
|
"169.254.0.0/16", // Link-local
|
||||||
"::1/128", // IPv6 loopback
|
"::1/128", // IPv6 loopback
|
||||||
"fe80::/10", // IPv6 link-local
|
"fe80::/10", // IPv6 link-local
|
||||||
"fc00::/7", // IPv6 unique local
|
"fc00::/7", // IPv6 unique local
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, cidr := range privateRanges {
|
for _, cidr := range privateRanges {
|
||||||
|
|
@ -2263,9 +2263,8 @@ func (n *NotificationManager) groupAlerts(alertList []*alerts.Alert) map[string]
|
||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendTestNotification sends a test notification
|
func buildNotificationTestAlert() *alerts.Alert {
|
||||||
func (n *NotificationManager) SendTestNotification(method string) error {
|
return &alerts.Alert{
|
||||||
testAlert := &alerts.Alert{
|
|
||||||
ID: "test-alert",
|
ID: "test-alert",
|
||||||
Type: "cpu",
|
Type: "cpu",
|
||||||
Level: "warning",
|
Level: "warning",
|
||||||
|
|
@ -2282,6 +2281,11 @@ func (n *NotificationManager) SendTestNotification(method string) error {
|
||||||
"resourceType": "vm",
|
"resourceType": "vm",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendTestNotification sends a test notification
|
||||||
|
func (n *NotificationManager) SendTestNotification(method string) error {
|
||||||
|
testAlert := buildNotificationTestAlert()
|
||||||
|
|
||||||
switch method {
|
switch method {
|
||||||
case "email":
|
case "email":
|
||||||
|
|
@ -2336,13 +2340,37 @@ func (n *NotificationManager) SendTestNotification(method string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use sendGroupedApprise with a single test alert
|
// Use sendGroupedApprise with a single test alert
|
||||||
n.sendGroupedApprise(appriseConfig, []*alerts.Alert{testAlert})
|
return n.sendGroupedApprise(appriseConfig, []*alerts.Alert{testAlert})
|
||||||
return nil
|
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown notification method: %s", method)
|
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
|
// SendTestWebhook sends a test notification to a specific webhook
|
||||||
func (n *NotificationManager) SendTestWebhook(webhook WebhookConfig) error {
|
func (n *NotificationManager) SendTestWebhook(webhook WebhookConfig) error {
|
||||||
// Create a test alert for webhook testing with realistic values
|
// 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) {
|
func TestSendTestNotificationAppriseHTTP(t *testing.T) {
|
||||||
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue