Implement container health alerts (#1679)

* Implement container health alerts, including a database migration for alert names and UI updates.

* Add bulk container alerts and improve alerting logic

* Implement container-specific alerts with new database collections, API endpoints, and UI components.
This commit is contained in:
Jaypalsinh Barad
2026-09-02 22:49:40 +05:30
committed by GitHub
parent 79ca31d770
commit 734c966307
16 changed files with 1408 additions and 8 deletions

View File

@@ -105,6 +105,8 @@ func NewAlertManager(app hubLike) *AlertManager {
func (am *AlertManager) bindEvents() {
am.hub.OnRecordAfterUpdateSuccess("alerts").BindFunc(updateHistoryOnAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("alerts").BindFunc(resolveHistoryOnAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("container_alerts").BindFunc(updateHistoryOnContainerAlertUpdate)
am.hub.OnRecordAfterDeleteSuccess("container_alerts").BindFunc(resolveHistoryOnContainerAlertDelete)
am.hub.OnRecordAfterUpdateSuccess("smart_devices").BindFunc(am.handleSmartDeviceAlert)
}

View File

@@ -117,3 +117,119 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
}
// UpsertUserContainerAlerts handles API request to create or update container alerts for a user
// across multiple containers (POST /api/beszel/user-container-alerts)
func UpsertUserContainerAlerts(e *core.RequestEvent) error {
userID := e.Auth.Id
reqData := struct {
Min uint8 `json:"min"`
Value float64 `json:"value"`
Name string `json:"name"`
Systems []string `json:"systems"`
Containers []string `json:"containers"`
Overwrite bool `json:"overwrite"`
}{}
err := e.BindBody(&reqData)
if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 || len(reqData.Containers) == 0 {
return e.BadRequestError("Bad data", err)
}
containerAlertsCollection, err := e.App.FindCachedCollectionByNameOrId("container_alerts")
if err != nil {
return err
}
err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems {
for _, containerId := range reqData.Containers {
// find existing matching alert
alertRecord, err := txApp.FindFirstRecordByFilter(containerAlertsCollection,
"system={:system} && container={:container} && name={:name} && user={:user}",
dbx.Params{"system": systemId, "container": containerId, "name": reqData.Name, "user": userID})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
// skip if alert already exists and overwrite is not set
if !reqData.Overwrite && alertRecord != nil {
continue
}
// create new alert if it doesn't exist
if alertRecord == nil {
alertRecord = core.NewRecord(containerAlertsCollection)
alertRecord.Set("user", userID)
alertRecord.Set("system", systemId)
alertRecord.Set("container", containerId)
alertRecord.Set("name", reqData.Name)
}
alertRecord.Set("value", reqData.Value)
alertRecord.Set("min", reqData.Min)
if err := txApp.SaveNoValidate(alertRecord); err != nil {
return err
}
}
}
return nil
})
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"success": true})
}
// DeleteUserContainerAlerts handles API request to delete container alerts for a user
// across multiple containers (DELETE /api/beszel/user-container-alerts)
func DeleteUserContainerAlerts(e *core.RequestEvent) error {
userID := e.Auth.Id
reqData := struct {
AlertName string `json:"name"`
Systems []string `json:"systems"`
Containers []string `json:"containers"`
}{}
err := e.BindBody(&reqData)
if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 || len(reqData.Containers) == 0 {
return e.BadRequestError("Bad data", err)
}
var numDeleted uint16
err = e.App.RunInTransaction(func(txApp core.App) error {
for _, systemId := range reqData.Systems {
for _, containerId := range reqData.Containers {
// Find existing alert to delete
alertRecord, err := txApp.FindFirstRecordByFilter("container_alerts",
"system={:system} && container={:container} && name={:name} && user={:user}",
dbx.Params{"system": systemId, "container": containerId, "name": reqData.AlertName, "user": userID})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// alert doesn't exist, continue to next container
continue
}
return err
}
if err := txApp.Delete(alertRecord); err != nil {
return err
}
numDeleted++
}
}
return nil
})
if err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
}

View File

@@ -0,0 +1,394 @@
package alerts
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
"github.com/spf13/cast"
)
// ContainerAlertData holds the data for a container alert
type ContainerAlertData struct {
systemRecord *core.Record
containerRecord *core.Record
alertRecord *core.Record
name string
unit string
val float64
threshold float64
triggered bool
time time.Time
count uint8
min uint8
descriptor string // override descriptor in notification body
}
// ContainerAlertStats represents the stats structure for container alerts
type ContainerAlertStats struct {
Cpu float64 `json:"c"`
Mem float64 `json:"m"`
NetworkSent float64 `json:"ns"`
NetworkRecv float64 `json:"nr"`
Status string `json:"s"`
Health string `json:"h"`
}
// HandleContainerAlerts processes container alerts for a system
func (am *AlertManager) HandleContainerAlerts(systemRecord *core.Record, data *system.CombinedData) error {
containers := data.Containers
if len(containers) == 0 {
return nil
}
// Get all container alerts for this system
alertRecords, err := am.hub.FindAllRecords("container_alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
)
if err != nil || len(alertRecords) == 0 {
return nil
}
// Group alerts by container
containerAlerts := make(map[string][]*core.Record)
for _, alertRecord := range alertRecords {
containerId := alertRecord.GetString("container")
containerAlerts[containerId] = append(containerAlerts[containerId], alertRecord)
}
// Process alerts for each container
for containerId, alerts := range containerAlerts {
// Find the container in the current stats
var containerStats *container.Stats
for _, c := range containers {
if c.Id == containerId {
containerStats = c
break
}
}
// If container not found in current stats, it might be stopped
// We'll still process Status and Health alerts
if containerStats == nil {
for _, alertRecord := range alerts {
name := alertRecord.GetString("name")
if name == "Status" || name == "Health" {
// Container is missing, so it's likely stopped
go am.sendContainerStatusAlert(alertRecord, systemRecord, containerId, "stopped")
}
}
continue
}
// Process each alert for this container
var validAlerts []ContainerAlertData
now := time.Now().UTC()
oldestTime := now
for _, alertRecord := range alerts {
name := alertRecord.GetString("name")
var val float64
unit := "%"
switch name {
case "CPU":
val = containerStats.Cpu
case "Memory":
val = containerStats.Mem
case "Network":
val = containerStats.NetworkSent + containerStats.NetworkRecv
unit = " MB/s"
case "Status":
// Status alert is handled differently - immediate trigger
triggered := alertRecord.GetBool("triggered")
isRunning := containerStats.Status == "running"
if (!triggered && !isRunning) || (triggered && isRunning) {
go am.sendContainerStatusAlert(alertRecord, systemRecord, containerId, containerStats.Status)
}
continue
case "Health":
// Health alert - trigger on unhealthy status
triggered := alertRecord.GetBool("triggered")
isHealthy := containerStats.Health == container.DockerHealthNone || containerStats.Health == container.DockerHealthHealthy
if (!triggered && !isHealthy) || (triggered && isHealthy) {
go am.sendContainerHealthAlert(alertRecord, systemRecord, containerId, containerStats.Health)
}
continue
default:
continue
}
triggered := alertRecord.GetBool("triggered")
threshold := alertRecord.GetFloat("value")
min := max(1, cast.ToUint8(alertRecord.Get("min")))
alert := ContainerAlertData{
systemRecord: systemRecord,
alertRecord: alertRecord,
name: name,
unit: unit,
val: val,
threshold: threshold,
triggered: triggered,
min: min,
}
// Send alert immediately if min is 1
if min == 1 {
alert.triggered = val > threshold
go am.sendContainerAlert(alert, containerId, containerStats.Name)
continue
}
alert.time = now.Add(-time.Duration(min) * time.Minute)
if alert.time.Before(oldestTime) {
oldestTime = alert.time
}
validAlerts = append(validAlerts, alert)
}
if len(validAlerts) == 0 {
continue
}
// Fetch historical container stats
containerStatsRecords := []struct {
Stats []byte `db:"stats"`
Created types.DateTime `db:"created"`
}{}
err = am.hub.DB().
Select("stats", "created").
From("container_stats").
Where(dbx.NewExp(
"system={:system} AND type='1m' AND created > {:created}",
dbx.Params{
"system": systemRecord.Id,
"created": oldestTime.Add(-time.Second * 90),
},
)).
OrderBy("created").
All(&containerStatsRecords)
if err != nil || len(containerStatsRecords) == 0 {
continue
}
oldestRecordTime := containerStatsRecords[0].Created.Time()
// Filter valid alerts
filteredAlerts := make([]ContainerAlertData, 0, len(validAlerts))
for _, alert := range validAlerts {
if alert.time.After(oldestRecordTime) {
filteredAlerts = append(filteredAlerts, alert)
}
}
validAlerts = filteredAlerts
if len(validAlerts) == 0 {
continue
}
// Process historical stats
for i := range containerStatsRecords {
stat := containerStatsRecords[i]
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
// Parse container stats array
var allContainerStats []*container.Stats
if err := json.Unmarshal(stat.Stats, &allContainerStats); err != nil {
continue
}
// Find this container in the stats
var thisContainerStats *container.Stats
for _, cs := range allContainerStats {
if cs.Id == containerId {
thisContainerStats = cs
break
}
}
if thisContainerStats == nil {
continue
}
for j := range validAlerts {
alert := &validAlerts[j]
if i == 0 {
alert.val = 0
}
if systemStatsCreation.Before(alert.time) {
continue
}
switch alert.name {
case "CPU":
alert.val += thisContainerStats.Cpu
case "Memory":
alert.val += thisContainerStats.Mem
case "Network":
alert.val += thisContainerStats.NetworkSent + thisContainerStats.NetworkRecv
default:
continue
}
alert.count++
}
}
// Calculate averages and send alerts
for _, alert := range validAlerts {
alert.val = alert.val / float64(alert.count)
minCount := float32(alert.min) / 1.2
if float32(alert.count) >= minCount {
if !alert.triggered && alert.val > alert.threshold {
alert.triggered = true
go am.sendContainerAlert(alert, containerId, containerStats.Name)
} else if alert.triggered && alert.val <= alert.threshold {
alert.triggered = false
go am.sendContainerAlert(alert, containerId, containerStats.Name)
}
}
}
}
return nil
}
// sendContainerAlert sends a container alert notification
func (am *AlertManager) sendContainerAlert(alert ContainerAlertData, containerId, containerName string) {
systemName := alert.systemRecord.GetString("name")
var subject string
if alert.triggered {
subject = fmt.Sprintf("%s container %s %s above threshold", systemName, containerName, strings.ToLower(alert.name))
} else {
subject = fmt.Sprintf("%s container %s %s below threshold", systemName, containerName, strings.ToLower(alert.name))
}
minutesLabel := "minute"
if alert.min > 1 {
minutesLabel += "s"
}
descriptor := alert.name
if alert.descriptor != "" {
descriptor = alert.descriptor
}
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", descriptor, alert.val, alert.unit, alert.min, minutesLabel)
alert.alertRecord.Set("triggered", alert.triggered)
if err := am.hub.Save(alert.alertRecord); err != nil {
return
}
am.SendAlert(AlertMessageData{
UserID: alert.alertRecord.GetString("user"),
SystemID: alert.systemRecord.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", alert.systemRecord.Id, "containers", containerId),
LinkText: "View " + containerName,
})
}
// sendContainerStatusAlert sends a container status change alert
func (am *AlertManager) sendContainerStatusAlert(alertRecord, systemRecord *core.Record, containerId, status string) {
triggered := alertRecord.GetBool("triggered")
isRunning := status == "running"
// Only send if state changed
if (!triggered && isRunning) || (triggered && !isRunning) {
return
}
systemName := systemRecord.GetString("name")
// Try to get container name from containers collection
containerName := containerId
if containerRecord, err := am.hub.FindFirstRecordByFilter("containers", "id={:id}", dbx.Params{"id": containerId}); err == nil {
containerName = containerRecord.GetString("name")
}
var subject string
if isRunning {
subject = fmt.Sprintf("%s container %s is now running", systemName, containerName)
} else {
subject = fmt.Sprintf("%s container %s has stopped", systemName, containerName)
}
body := fmt.Sprintf("Container status changed to: %s", status)
alertRecord.Set("triggered", !isRunning)
if err := am.hub.Save(alertRecord); err != nil {
return
}
am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"),
SystemID: systemRecord.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", systemRecord.Id, "containers", containerId),
LinkText: "View " + containerName,
})
}
// sendContainerHealthAlert sends a container health status change alert
func (am *AlertManager) sendContainerHealthAlert(alertRecord, systemRecord *core.Record, containerId string, health container.DockerHealth) {
triggered := alertRecord.GetBool("triggered")
isHealthy := health == container.DockerHealthNone || health == container.DockerHealthHealthy
// Only send if state changed
if (!triggered && isHealthy) || (triggered && !isHealthy) {
return
}
systemName := systemRecord.GetString("name")
// Try to get container name from containers collection
containerName := containerId
if containerRecord, err := am.hub.FindFirstRecordByFilter("containers", "id={:id}", dbx.Params{"id": containerId}); err == nil {
containerName = containerRecord.GetString("name")
}
var subject string
if isHealthy {
subject = fmt.Sprintf("%s container %s is now healthy", systemName, containerName)
} else {
subject = fmt.Sprintf("%s container %s is unhealthy", systemName, containerName)
}
// Convert DockerHealth to string
healthStatus := "unknown"
for str, val := range container.DockerHealthStrings {
if val == health {
healthStatus = str
break
}
}
body := fmt.Sprintf("Container health status changed to: %s", healthStatus)
alertRecord.Set("triggered", !isHealthy)
if err := am.hub.Save(alertRecord); err != nil {
return
}
am.SendAlert(AlertMessageData{
UserID: alertRecord.GetString("user"),
SystemID: systemRecord.Id,
Title: subject,
Message: body,
Link: am.hub.MakeLink("system", systemRecord.Id, "containers", containerId),
LinkText: "View " + containerName,
})
}

View File

@@ -72,3 +72,70 @@ func createAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHist
}
return alertHistoryRecord, err
}
// On triggered container alert record delete, set matching alert history record to resolved
func resolveHistoryOnContainerAlertDelete(e *core.RecordEvent) error {
if !e.Record.GetBool("triggered") {
return e.Next()
}
_ = resolveContainerAlertHistoryRecord(e.App, e.Record.Id)
return e.Next()
}
// On container alert record update, update alert history record
func updateHistoryOnContainerAlertUpdate(e *core.RecordEvent) error {
original := e.Record.Original()
new := e.Record
originalTriggered := original.GetBool("triggered")
newTriggered := new.GetBool("triggered")
// no need to update alert history if triggered state has not changed
if originalTriggered == newTriggered {
return e.Next()
}
// if new state is triggered, create new alert history record
if newTriggered {
_, _ = createContainerAlertHistoryRecord(e.App, new)
return e.Next()
}
// if new state is not triggered, check for matching alert history record and set it to resolved
_ = resolveContainerAlertHistoryRecord(e.App, new.Id)
return e.Next()
}
// resolveContainerAlertHistoryRecord sets the resolved field to the current time
func resolveContainerAlertHistoryRecord(app core.App, alertRecordID string) error {
alertHistoryRecord, err := app.FindFirstRecordByFilter("alerts_history", "alert_id={:alert_id} && resolved=null", dbx.Params{"alert_id": alertRecordID})
if err != nil || alertHistoryRecord == nil {
return err
}
alertHistoryRecord.Set("resolved", time.Now().UTC())
err = app.Save(alertHistoryRecord)
if err != nil {
app.Logger().Error("Failed to resolve container alert history", "err", err)
}
return err
}
// createContainerAlertHistoryRecord creates a new container alert history record
func createContainerAlertHistoryRecord(app core.App, alertRecord *core.Record) (alertHistoryRecord *core.Record, err error) {
alertHistoryCollection, err := app.FindCachedCollectionByNameOrId("alerts_history")
if err != nil {
return nil, err
}
alertHistoryRecord = core.NewRecord(alertHistoryCollection)
alertHistoryRecord.Set("alert_id", alertRecord.Id)
alertHistoryRecord.Set("user", alertRecord.GetString("user"))
alertHistoryRecord.Set("system", alertRecord.GetString("system"))
alertHistoryRecord.Set("container", alertRecord.GetString("container"))
alertHistoryRecord.Set("name", alertRecord.GetString("name"))
alertHistoryRecord.Set("value", alertRecord.GetFloat("value"))
err = app.Save(alertHistoryRecord)
if err != nil {
app.Logger().Error("Failed to save container alert history", "err", err)
}
return alertHistoryRecord, err
}