Fix multi-tenant persistence and backend stability

- Initialize Alert and Notification managers with tenant-specific data directories

- Add panic recovery to WebSocket safeSend for stability

- Record host metrics to history for sparkline support
This commit is contained in:
rcourtman 2026-02-03 16:24:42 +00:00
parent bea3bbe5f6
commit aeca5e39fa
4 changed files with 70 additions and 8 deletions

View file

@ -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),

View file

@ -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),

View file

@ -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

View file

@ -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