diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index 53790b4630..e4c6c3bf3d 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -560,9 +560,16 @@ type dockerRestartRecord struct { lastChecked time.Time } -// NewManager creates a new alert manager +// NewManager creates a new alert manager using the global data directory. +// For multi-tenant deployments, use NewManagerWithDataDir instead. func NewManager() *Manager { - alertsDir := filepath.Join(utils.GetDataDir(), "alerts") + return NewManagerWithDataDir(utils.GetDataDir()) +} + +// NewManagerWithDataDir creates a new alert manager with a custom data directory. +// This enables tenant-scoped alert persistence in multi-tenant deployments. +func NewManagerWithDataDir(dataDir string) *Manager { + alertsDir := filepath.Join(dataDir, "alerts") alertOrphaned := true m := &Manager{ activeAlerts: make(map[string]*Alert), diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 0a5c38b058..9c8b86e991 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2662,6 +2662,41 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config. m.alertManager.CheckHost(host) } + // Record Host metrics for sparkline charts + now := time.Now() + hostMetricKey := fmt.Sprintf("host:%s", host.ID) + + // Record host CPU usage + m.metricsHistory.AddGuestMetric(hostMetricKey, "cpu", host.CPUUsage, now) + + // Record host Memory usage + m.metricsHistory.AddGuestMetric(hostMetricKey, "memory", host.Memory.Usage, now) + + // Record host Disk usage (use first disk or calculate aggregate) + var hostDiskPercent float64 + if len(host.Disks) > 0 { + hostDiskPercent = host.Disks[0].Usage + } + m.metricsHistory.AddGuestMetric(hostMetricKey, "disk", hostDiskPercent, now) + + // Record host Network I/O (sum across all interfaces) + var totalRXBytes, totalTXBytes uint64 + for _, nic := range host.NetworkInterfaces { + totalRXBytes += nic.RXBytes + totalTXBytes += nic.TXBytes + } + m.metricsHistory.AddGuestMetric(hostMetricKey, "netin", float64(totalRXBytes), now) + m.metricsHistory.AddGuestMetric(hostMetricKey, "netout", float64(totalTXBytes), now) + + // Also write to persistent SQLite store + if m.metricsStore != nil { + m.metricsStore.Write("host", host.ID, "cpu", host.CPUUsage, now) + m.metricsStore.Write("host", host.ID, "memory", host.Memory.Usage, now) + m.metricsStore.Write("host", host.ID, "disk", hostDiskPercent, now) + m.metricsStore.Write("host", host.ID, "netin", float64(totalRXBytes), now) + m.metricsStore.Write("host", host.ID, "netout", float64(totalTXBytes), now) + } + return host, nil } @@ -3600,9 +3635,9 @@ func New(cfg *config.Config) (*Monitor, error) { rateTracker: NewRateTracker(), metricsHistory: NewMetricsHistory(1000, 24*time.Hour), // Keep up to 1000 points or 24 hours metricsStore: metricsStore, // Persistent SQLite storage - alertManager: alerts.NewManager(), + alertManager: alerts.NewManagerWithDataDir(cfg.DataPath), incidentStore: incidentStore, - notificationMgr: notifications.NewNotificationManager(cfg.PublicURL), + notificationMgr: notifications.NewNotificationManagerWithDataDir(cfg.PublicURL, cfg.DataPath), configPersist: config.NewConfigPersistence(cfg.DataPath), discoveryService: nil, // Will be initialized in Start() authFailures: make(map[string]int), diff --git a/internal/notifications/notifications.go b/internal/notifications/notifications.go index 7e8dd943f3..f09a3b822e 100644 --- a/internal/notifications/notifications.go +++ b/internal/notifications/notifications.go @@ -343,8 +343,16 @@ type AppriseConfig struct { SkipTLSVerify bool `json:"skipTlsVerify,omitempty"` } -// NewNotificationManager creates a new notification manager +// NewNotificationManager creates a new notification manager using the global data directory. +// For multi-tenant deployments, use NewNotificationManagerWithDataDir instead. func NewNotificationManager(publicURL string) *NotificationManager { + return NewNotificationManagerWithDataDir(publicURL, "") +} + +// NewNotificationManagerWithDataDir creates a new notification manager with a custom data directory. +// This enables tenant-scoped notification queue persistence in multi-tenant deployments. +// If dataDir is empty, it uses the global data directory. +func NewNotificationManagerWithDataDir(publicURL string, dataDir string) *NotificationManager { cleanURL := strings.TrimRight(strings.TrimSpace(publicURL), "/") if cleanURL != "" { log.Info().Str("publicURL", cleanURL).Msg("NotificationManager initialized with public URL") @@ -352,8 +360,8 @@ func NewNotificationManager(publicURL string) *NotificationManager { log.Info().Msg("NotificationManager initialized without public URL - webhook links may not work") } - // Initialize persistent queue - queue, err := NewNotificationQueue("") + // Initialize persistent queue with tenant-specific data directory + queue, err := NewNotificationQueue(dataDir) if err != nil { log.Error().Err(err).Msg("Failed to initialize notification queue, notifications will be in-memory only") queue = nil diff --git a/internal/websocket/hub.go b/internal/websocket/hub.go index 6ff402be61..4834df078d 100644 --- a/internal/websocket/hub.go +++ b/internal/websocket/hub.go @@ -185,10 +185,22 @@ type Client struct { // safeSend attempts to send data to the client's send channel. // Returns false if the client is closed or the channel buffer is full. -func (c *Client) safeSend(data []byte) bool { +// Uses defer/recover to handle the race between close(c.send) and send. +func (c *Client) safeSend(data []byte) (sent bool) { + // Early check to avoid most attempts on closed clients. if c.closed.Load() { return false } + + // Recover from panic if the channel was closed between the check above + // and the send below. This is a defensive pattern to prevent server crashes. + defer func() { + if r := recover(); r != nil { + // Channel was closed concurrently; mark as not sent. + sent = false + } + }() + select { case c.send <- data: return true