diff --git a/webapp/backend/pkg/web/heartbeat_monitor.go b/webapp/backend/pkg/web/heartbeat_monitor.go index 2b54415b0..778aeebf4 100644 --- a/webapp/backend/pkg/web/heartbeat_monitor.go +++ b/webapp/backend/pkg/web/heartbeat_monitor.go @@ -7,6 +7,7 @@ import ( "github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg/database" + "github.com/analogj/scrutiny/webapp/backend/pkg/models" "github.com/analogj/scrutiny/webapp/backend/pkg/notify" "github.com/sirupsen/logrus" ) @@ -187,10 +188,7 @@ func (m *HeartbeatMonitor) checkAndSendHeartbeat() { deviceRepo, err := m.getOrCreateRepo() if err != nil { m.logger.Errorf("Failed to get/create repository for heartbeat: %v", err) - m.statusMu.Lock() - m.lastError = err - m.lastErrorTime = time.Now() - m.statusMu.Unlock() + m.recordError(err) return } @@ -198,18 +196,13 @@ func (m *HeartbeatMonitor) checkAndSendHeartbeat() { if err != nil { m.resetRepo() m.logger.Errorf("Failed to load settings for heartbeat: %v", err) - m.statusMu.Lock() - m.lastError = err - m.lastErrorTime = time.Now() - m.statusMu.Unlock() + m.recordError(err) return } if settings == nil || !settings.Metrics.HeartbeatEnabled { m.logger.Debug("Heartbeat notifications are disabled") - m.statusMu.Lock() - m.lastError = nil - m.statusMu.Unlock() + m.clearError() return } @@ -217,32 +210,16 @@ func (m *HeartbeatMonitor) checkAndSendHeartbeat() { if err != nil { m.resetRepo() m.logger.Errorf("Failed to load devices for heartbeat: %v", err) - m.statusMu.Lock() - m.lastError = err - m.lastErrorTime = time.Now() - m.statusMu.Unlock() + m.recordError(err) return } // Clear previous error on successful data load - m.statusMu.Lock() - m.lastError = nil - m.statusMu.Unlock() + m.clearError() // Filter to monitored devices (not archived, not muted) totalCount := len(devices) - monitoredCount := 0 - allHealthy := true - - for _, device := range devices { - if device.Archived || device.Muted { - continue - } - monitoredCount++ - if device.DeviceStatus != pkg.DeviceStatusPassed { - allHealthy = false - } - } + monitoredCount, allHealthy := countHealthyMonitored(devices) // Don't send heartbeat if no monitored devices if monitoredCount == 0 { @@ -258,22 +235,59 @@ func (m *HeartbeatMonitor) checkAndSendHeartbeat() { // All monitored drives are healthy -- send heartbeat m.logger.Infof("All %d monitored drives healthy, sending heartbeat notification", monitoredCount) + m.sendHeartbeatNotification(monitoredCount, totalCount, settings) +} + +// recordError stores the most recent heartbeat error and the time it occurred. +func (m *HeartbeatMonitor) recordError(err error) { + m.statusMu.Lock() + m.lastError = err + m.lastErrorTime = time.Now() + m.statusMu.Unlock() +} +// clearError clears the most recent heartbeat error after a successful operation. +func (m *HeartbeatMonitor) clearError() { + m.statusMu.Lock() + m.lastError = nil + m.statusMu.Unlock() +} + +// countHealthyMonitored returns the count of monitored (non-archived, non-muted) devices and +// whether all of them currently report a passed status. +func countHealthyMonitored(devices []models.Device) (monitoredCount int, allHealthy bool) { + allHealthy = true + for i := range devices { + if devices[i].Archived || devices[i].Muted { + continue + } + monitoredCount++ + if devices[i].DeviceStatus != pkg.DeviceStatusPassed { + allHealthy = false + } + } + return monitoredCount, allHealthy +} + +// sendHeartbeatNotification builds and dispatches the heartbeat, routing through the notification +// gate when present (bypassing quiet hours, as heartbeats are informational). +func (m *HeartbeatMonitor) sendHeartbeatNotification(monitoredCount, totalCount int, settings *models.Settings) { notification := notify.NewHeartbeat(m.logger, m.appEngine.Config, monitoredCount, totalCount) notification.LoadHeartbeatDatabaseUrls(m.ctx, m.deviceRepo) - // Route through notification gate (bypass quiet hours -- heartbeats are informational) if gate := m.appEngine.NotificationGate; gate != nil { gate.TrySend(¬ification, settings, true) - } else { - if err := notification.Send(); err != nil { - if err.Error() == "no notification endpoints configured" { - m.logger.Warn("Heartbeat ready but no notification endpoints are configured. Configure notify.urls in scrutiny.yaml to receive heartbeat alerts.") - return - } - m.logger.Errorf("Failed to send heartbeat notification: %v", err) + m.logger.Info("Heartbeat notification sent successfully") + return + } + + if err := notification.Send(); err != nil { + if err.Error() == "no notification endpoints configured" { + m.logger.Warn("Heartbeat ready but no notification endpoints are configured. Configure notify.urls in scrutiny.yaml to receive heartbeat alerts.") return } + m.logger.Errorf("Failed to send heartbeat notification: %v", err) + return } m.logger.Info("Heartbeat notification sent successfully") diff --git a/webapp/backend/pkg/web/middleware/auth.go b/webapp/backend/pkg/web/middleware/auth.go index 35e9cbe62..fd539acca 100644 --- a/webapp/backend/pkg/web/middleware/auth.go +++ b/webapp/backend/pkg/web/middleware/auth.go @@ -182,58 +182,63 @@ func AuthMiddleware(appConfig config.Interface, logger *logrus.Entry) gin.Handle return func(c *gin.Context) { c.Set("AUTH_ENABLED", ac.authEnabled) - requestPath := c.Request.URL.Path - - // When general auth is disabled, only metrics may require its own token. - if !ac.authEnabled { - if ac.metricsToken != "" && isMetricsPath(requestPath) && !ac.validateMetricsToken(c) { - rejectUnauthorized(c, "Metrics endpoint requires authentication. Provide a Bearer token.") - return - } - c.Next() - return - } + ac.authorize(c) + } +} - // Served docs can be public or private independently of the broader auth toggle. - if isDocsPath(requestPath) { - if ac.docsPublic { - c.Next() - return - } - if ac.validateGeneralAuth(c) { - c.Next() - return - } - rejectMissingOrInvalid(c) - return - } +// authorize applies the request-path-based authentication rules for a single request. +func (ac *authContext) authorize(c *gin.Context) { + requestPath := c.Request.URL.Path - // Non-API routes (frontend static files, SPA routes) bypass auth. - // Only /api/* paths are subject to authentication. - if !strings.Contains(requestPath, "/api/") { - c.Next() - return - } + if !ac.authEnabled { + ac.handleAuthDisabled(c, requestPath) + return + } - // Public API routes bypass authentication entirely. - if isPublicPath(requestPath) { - c.Next() - return - } + // Served docs can be public or private independently of the broader auth toggle. + if isDocsPath(requestPath) { + ac.handleDocsPath(c) + return + } - // Dedicated metrics token: accepted as an alternative for /api/metrics. - if isMetricsPath(requestPath) && ac.validateMetricsToken(c) { - c.Set("AUTH_TYPE", "metrics_token") - c.Next() - return - } + // Non-API routes (frontend static files, SPA routes) and public API routes bypass auth. + // Only authenticated /api/* paths are subject to authentication. + if !strings.Contains(requestPath, "/api/") || isPublicPath(requestPath) { + c.Next() + return + } - // General auth: API token or JWT. - if ac.validateGeneralAuth(c) { - c.Next() - return - } + // Dedicated metrics token: accepted as an alternative for /api/metrics. + if isMetricsPath(requestPath) && ac.validateMetricsToken(c) { + c.Set("AUTH_TYPE", "metrics_token") + c.Next() + return + } + + // General auth: API token or JWT. + if ac.validateGeneralAuth(c) { + c.Next() + return + } + + rejectMissingOrInvalid(c) +} + +// handleAuthDisabled handles requests when general auth is disabled: only the metrics endpoint may +// still require its own token. +func (ac *authContext) handleAuthDisabled(c *gin.Context, requestPath string) { + if ac.metricsToken != "" && isMetricsPath(requestPath) && !ac.validateMetricsToken(c) { + rejectUnauthorized(c, "Metrics endpoint requires authentication. Provide a Bearer token.") + return + } + c.Next() +} - rejectMissingOrInvalid(c) +// handleDocsPath authorizes the served docs path, which may be public or require general auth. +func (ac *authContext) handleDocsPath(c *gin.Context) { + if ac.docsPublic || ac.validateGeneralAuth(c) { + c.Next() + return } + rejectMissingOrInvalid(c) } diff --git a/webapp/backend/pkg/web/middleware/logger.go b/webapp/backend/pkg/web/middleware/logger.go index 8540d5fa6..ec8e46ec4 100644 --- a/webapp/backend/pkg/web/middleware/logger.go +++ b/webapp/backend/pkg/web/middleware/logger.go @@ -36,16 +36,7 @@ func LoggerMiddleware(logger *logrus.Entry) gin.HandlerFunc { } return func(c *gin.Context) { - - //clone the request body reader. - var reqBody string - if c.Request.Body != nil { - buf, _ := ioutil.ReadAll(c.Request.Body) - reqBodyReader1 := ioutil.NopCloser(bytes.NewBuffer(buf)) - reqBodyReader2 := ioutil.NopCloser(bytes.NewBuffer(buf)) //We have to create a new Buffer, because reqBodyReader1 will be read. - c.Request.Body = reqBodyReader2 - reqBody = readBody(reqBodyReader1) - } + reqBody := cloneRequestBody(c) // other handler can change c.Path so: path := c.Request.URL.Path @@ -54,8 +45,7 @@ func LoggerMiddleware(logger *logrus.Entry) gin.HandlerFunc { c.Set("LOGGER", logger) start := time.Now() c.Next() - stop := time.Since(start) - latency := int(math.Ceil(float64(stop.Nanoseconds()) / 1000000.0)) + latency := int(math.Ceil(float64(time.Since(start).Nanoseconds()) / 1000000.0)) statusCode := c.Writer.Status() clientIP := c.ClientIP() clientUserAgent := c.Request.UserAgent() @@ -77,26 +67,51 @@ func LoggerMiddleware(logger *logrus.Entry) gin.HandlerFunc { "userAgent": clientUserAgent, }) - if len(c.Errors) > 0 { - entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String()) - } else { - msg := fmt.Sprintf("%s - %s [%s] \"%s %s\" %d %d \"%s\" \"%s\" (%dms)", clientIP, hostname, time.Now().Format(timeFormat), c.Request.Method, path, statusCode, respLength, referer, clientUserAgent, latency) - if statusCode >= http.StatusInternalServerError { - entry.Error(msg) - } else if statusCode >= http.StatusBadRequest { - entry.Warn(msg) - } else { - entry.Info(msg) - } - } - if strings.Contains(path, "/api/") { - //only debug log request/response from api endpoint. - if len(reqBody) > 0 { - entry.WithField("bodyType", "request").Debugln(reqBody) // Print request body - } - entry.WithField("bodyType", "response").Debugln(blw.body.String()) - } + //nolint:gocritic // fixed access-log format; %q would change the quoting/escaping + msg := fmt.Sprintf("%s - %s [%s] \"%s %s\" %d %d \"%s\" \"%s\" (%dms)", clientIP, hostname, time.Now().Format(timeFormat), c.Request.Method, path, statusCode, respLength, referer, clientUserAgent, latency) + logRequestResult(c, entry, msg, statusCode) + logAPIBodies(entry, path, reqBody, blw.body.String()) + } +} + +// cloneRequestBody reads and replaces the request body so it can be both logged and consumed +// downstream, returning the body content (empty when there is no body). +func cloneRequestBody(c *gin.Context) string { + if c.Request.Body == nil { + return "" + } + buf, _ := ioutil.ReadAll(c.Request.Body) + // We have to create a new Buffer because the read reader will be consumed. + c.Request.Body = ioutil.NopCloser(bytes.NewBuffer(buf)) + return readBody(ioutil.NopCloser(bytes.NewBuffer(buf))) +} + +// logRequestResult logs the request at the level appropriate for its status code, or logs the +// private gin errors when present. +func logRequestResult(c *gin.Context, entry *logrus.Entry, msg string, statusCode int) { + if len(c.Errors) > 0 { + entry.Error(c.Errors.ByType(gin.ErrorTypePrivate).String()) + return + } + switch { + case statusCode >= http.StatusInternalServerError: + entry.Error(msg) + case statusCode >= http.StatusBadRequest: + entry.Warn(msg) + default: + entry.Info(msg) + } +} + +// logAPIBodies debug-logs the request and response bodies for /api/ endpoints only. +func logAPIBodies(entry *logrus.Entry, path, reqBody, respBody string) { + if !strings.Contains(path, "/api/") { + return + } + if len(reqBody) > 0 { + entry.WithField("bodyType", "request").Debugln(reqBody) // Print request body } + entry.WithField("bodyType", "response").Debugln(respBody) } // Response Logging diff --git a/webapp/backend/pkg/web/missed_ping_monitor.go b/webapp/backend/pkg/web/missed_ping_monitor.go index 0cf6e1973..76fcb4de3 100644 --- a/webapp/backend/pkg/web/missed_ping_monitor.go +++ b/webapp/backend/pkg/web/missed_ping_monitor.go @@ -444,90 +444,35 @@ func (m *MissedPingMonitor) GetStatus(ctx context.Context) (*models.MissedPingSt if err != nil { return nil, fmt.Errorf("failed to load settings: %w", err) } - - // Set defaults if not configured - timeoutMinutes := DefaultMissedPingTimeoutMinutes - checkInterval := DefaultMissedPingCheckIntervalMins - enabled := false - - if settings != nil { - enabled = settings.Metrics.NotifyOnMissedPing - if settings.Metrics.MissedPingTimeoutMinutes > 0 { - timeoutMinutes = settings.Metrics.MissedPingTimeoutMinutes - } - if settings.Metrics.MissedPingCheckIntervalMins > 0 { - checkInterval = settings.Metrics.MissedPingCheckIntervalMins - } - } + timeoutMinutes, checkInterval, enabled := resolveMissedPingSettings(settings) // Get device counts devices, err := deviceRepo.GetDevices(ctx) if err != nil { return nil, fmt.Errorf("failed to get devices: %w", err) } + monitoredDevices := countMonitoredDevices(devices) - totalDevices := len(devices) - monitoredDevices := 0 - for _, device := range devices { - if !device.Archived && !device.Muted { - monitoredDevices++ - } - } - - // Get last seen times from InfluxDB + // Get last seen times from InfluxDB, then build the notified-device details lastSeenTimes, _ := deviceRepo.GetDevicesLastSeenTimes(ctx) - - // Get notified devices info - m.mu.RLock() - notifiedDevices := make([]string, 0, len(m.notifiedDevices)) - notifiedDetails := make([]models.NotifiedDeviceInfo, 0, len(m.notifiedDevices)) - - for devID, notifyTime := range m.notifiedDevices { - notifiedDevices = append(notifiedDevices, devID) - - // Find device details - var deviceName, deviceWWN string - for _, device := range devices { - if device.DeviceID == devID { - deviceName = device.DeviceName - deviceWWN = device.WWN - break - } - } - - // Get last seen time from InfluxDB (keyed by DeviceID) - var lastSeenTime time.Time - if lst, ok := lastSeenTimes[devID]; ok { - lastSeenTime = lst - } - - notifiedDetails = append(notifiedDetails, models.NotifiedDeviceInfo{ - DeviceID: devID, - WWN: deviceWWN, - DeviceName: deviceName, - NotificationTime: notifyTime.Format(time.RFC3339), - LastSeenTime: lastSeenTime.Format(time.RFC3339), - }) - } - m.mu.RUnlock() + notifiedDevices, notifiedDetails := m.buildNotifiedDeviceDetails(devices, lastSeenTimes) // Validate InfluxDB buckets influxStatus := m.validateInfluxDBBuckets(ctx, deviceRepo) // Check notification configuration notifyUrls := m.appEngine.Config.GetStringSlice("notify.urls") - notifyConfigured := len(notifyUrls) > 0 status := &models.MissedPingStatusData{ Enabled: enabled, TimeoutMinutes: timeoutMinutes, CheckIntervalMinutes: checkInterval, - NotifyConfigured: notifyConfigured, + NotifyConfigured: len(notifyUrls) > 0, NotifyEndpointCount: len(notifyUrls), LastCheckTime: lastCheck.Format(time.RFC3339), NextCheckTime: nextCheck.Format(time.RFC3339), MonitorRunning: m.ctx.Err() == nil, - TotalDevices: totalDevices, + TotalDevices: len(devices), MonitoredDevices: monitoredDevices, NotifiedDevices: notifiedDevices, NotifiedDevicesDetails: notifiedDetails, @@ -542,6 +487,77 @@ func (m *MissedPingMonitor) GetStatus(ctx context.Context) (*models.MissedPingSt return status, nil } +// resolveMissedPingSettings derives the effective timeout/interval/enabled values from settings, +// applying defaults when settings is nil or unset. +func resolveMissedPingSettings(settings *models.Settings) (timeoutMinutes, checkInterval int, enabled bool) { + timeoutMinutes = DefaultMissedPingTimeoutMinutes + checkInterval = DefaultMissedPingCheckIntervalMins + if settings == nil { + return timeoutMinutes, checkInterval, false + } + + enabled = settings.Metrics.NotifyOnMissedPing + if settings.Metrics.MissedPingTimeoutMinutes > 0 { + timeoutMinutes = settings.Metrics.MissedPingTimeoutMinutes + } + if settings.Metrics.MissedPingCheckIntervalMins > 0 { + checkInterval = settings.Metrics.MissedPingCheckIntervalMins + } + return timeoutMinutes, checkInterval, enabled +} + +// countMonitoredDevices counts devices that are neither archived nor muted. +func countMonitoredDevices(devices []models.Device) int { + count := 0 + for i := range devices { + if !devices[i].Archived && !devices[i].Muted { + count++ + } + } + return count +} + +// buildNotifiedDeviceDetails snapshots the currently-notified devices with their names, WWNs, and +// last-seen times for the status response. +func (m *MissedPingMonitor) buildNotifiedDeviceDetails(devices []models.Device, lastSeenTimes map[string]time.Time) ([]string, []models.NotifiedDeviceInfo) { + m.mu.RLock() + defer m.mu.RUnlock() + + notifiedDevices := make([]string, 0, len(m.notifiedDevices)) + notifiedDetails := make([]models.NotifiedDeviceInfo, 0, len(m.notifiedDevices)) + + for devID, notifyTime := range m.notifiedDevices { + notifiedDevices = append(notifiedDevices, devID) + + deviceName, deviceWWN := deviceNameAndWWN(devices, devID) + + // Get last seen time from InfluxDB (keyed by DeviceID) + var lastSeenTime time.Time + if lst, ok := lastSeenTimes[devID]; ok { + lastSeenTime = lst + } + + notifiedDetails = append(notifiedDetails, models.NotifiedDeviceInfo{ + DeviceID: devID, + WWN: deviceWWN, + DeviceName: deviceName, + NotificationTime: notifyTime.Format(time.RFC3339), + LastSeenTime: lastSeenTime.Format(time.RFC3339), + }) + } + return notifiedDevices, notifiedDetails +} + +// deviceNameAndWWN returns the device name and WWN for the given device ID, or empty strings. +func deviceNameAndWWN(devices []models.Device, devID string) (string, string) { + for i := range devices { + if devices[i].DeviceID == devID { + return devices[i].DeviceName, devices[i].WWN + } + } + return "", "" +} + // validateInfluxDBBuckets checks if all required InfluxDB buckets exist func (m *MissedPingMonitor) validateInfluxDBBuckets(ctx context.Context, repo database.DeviceRepo) models.InfluxDBStatusInfo { // Get base bucket name from config