// Package alerts handles alert management and delivery. package alerts import ( "beszel/internal/entities/system" "fmt" "log" "net/mail" "net/url" "os" "github.com/containrrr/shoutrrr" "github.com/labstack/echo/v5" "github.com/pocketbase/dbx" "github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/tools/mailer" ) type AlertData struct { User *models.Record Title string Message string Link string LinkText string } type AlertManager struct { app *pocketbase.PocketBase } func NewAlertManager(app *pocketbase.PocketBase) *AlertManager { return &AlertManager{ app: app, } } func (am *AlertManager) HandleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) { alertRecords, err := am.app.Dao().FindRecordsByExpr("alerts", dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.GetId()}), ) if err != nil || len(alertRecords) == 0 { // log.Println("no alerts found for system") return } // log.Println("found alerts", len(alertRecords)) var systemInfo *system.Info for _, alertRecord := range alertRecords { name := alertRecord.GetString("name") switch name { case "Status": am.handleStatusAlerts(newStatus, oldRecord, alertRecord) case "CPU", "Memory", "Disk": if newStatus != "up" { continue } if systemInfo == nil { systemInfo = getSystemInfo(newRecord) } if name == "CPU" { am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.Cpu) } else if name == "Memory" { am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.MemPct) } else if name == "Disk" { am.handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.DiskPct) } } } } func getSystemInfo(record *models.Record) *system.Info { var SystemInfo system.Info record.UnmarshalJSONField("info", &SystemInfo) return &SystemInfo } func (am *AlertManager) handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) { triggered := alertRecord.GetBool("triggered") threshold := alertRecord.GetFloat("value") // fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered) var subject string var body string var systemName string if !triggered && curValue > threshold { alertRecord.Set("triggered", true) systemName = newRecord.GetString("name") subject = fmt.Sprintf("%s usage above threshold on %s", name, systemName) body = fmt.Sprintf("%s usage on %s is %.1f%%.", name, systemName, curValue) } else if triggered && curValue <= threshold { alertRecord.Set("triggered", false) systemName = newRecord.GetString("name") subject = fmt.Sprintf("%s usage below threshold on %s", name, systemName) body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.", name, systemName, curValue) } else { // fmt.Println(name, "not triggered") return } if err := am.app.Dao().SaveRecord(alertRecord); err != nil { // app.Logger().Error("failed to save alert record", "err", err.Error()) return } // expand the user relation and send the alert if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 { // app.Logger().Error("failed to expand user relation", "errs", errs) return } if user := alertRecord.ExpandedOne("user"); user != nil { am.sendAlert(AlertData{ User: user, Title: subject, Message: body, Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName), LinkText: "View " + systemName, }) } } func (am *AlertManager) handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord *models.Record) error { var alertStatus string switch newStatus { case "up": if oldRecord.GetString("status") == "down" { alertStatus = "up" } case "down": if oldRecord.GetString("status") == "up" { alertStatus = "down" } } if alertStatus == "" { return nil } // expand the user relation if errs := am.app.Dao().ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 { return fmt.Errorf("failed to expand: %v", errs) } user := alertRecord.ExpandedOne("user") if user == nil { return nil } emoji := "\U0001F534" if alertStatus == "up" { emoji = "\u2705" } // send alert systemName := oldRecord.GetString("name") am.sendAlert(AlertData{ User: user, Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus), Link: am.app.Settings().Meta.AppUrl + "/system/" + url.QueryEscape(systemName), LinkText: "View " + systemName, }) return nil } func (am *AlertManager) sendAlert(data AlertData) { shoutrrrUrl := os.Getenv("SHOUTRRR_URL") if shoutrrrUrl != "" { err := am.SendShoutrrrAlert(shoutrrrUrl, data.Title, data.Message, data.Link, data.LinkText) if err == nil { log.Println("Sent shoutrrr alert") return } log.Println("Failed to send alert via shoutrrr, falling back to email notification. ", "err", err.Error()) } // todo: email enable / disable and testing message := mailer.Message{ To: []mail.Address{{Address: data.User.GetString("email")}}, Subject: data.Title, Text: data.Message + fmt.Sprintf("\n\n%s", data.Link), From: mail.Address{ Address: am.app.Settings().Meta.SenderAddress, Name: am.app.Settings().Meta.SenderName, }, } log.Println("Sending alert via email") if err := am.app.NewMailClient().Send(&message); err != nil { am.app.Logger().Error("Failed to send alert: ", "err", err.Error()) } else { am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject) } } func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error { // services that support title param supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"} // Parse the URL parsedURL, err := url.Parse(notificationUrl) if err != nil { return fmt.Errorf("error parsing URL: %v", err) } scheme := parsedURL.Scheme queryParams := parsedURL.Query() // Add title if sliceContains(supportsTitle, scheme) { queryParams.Add("title", title) } else if scheme == "mattermost" { // use markdown title for mattermost message = "##### " + title + "\n\n" + message } else if scheme == "generic" && queryParams.Has("template") { // add title as property if using generic with template json titleKey := queryParams.Get("titlekey") if titleKey == "" { titleKey = "title" } queryParams.Add("$"+titleKey, title) } else { // otherwise just add title to message message = title + "\n\n" + message } // Add link if scheme == "ntfy" { // if ntfy, add link to actions queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link)) } else { // else add link directly to the message message += "\n\n" + link } // Encode the modified query parameters back into the URL parsedURL.RawQuery = queryParams.Encode() // log.Println("URL after modification:", parsedURL.String()) err = shoutrrr.Send(parsedURL.String(), message) if err == nil { am.app.Logger().Info("Sent shoutrrr alert", "title", title) } else { am.app.Logger().Error("Error sending shoutrrr alert", "errs", err) return err } return nil } // Contains checks if a string is present in a slice of strings func sliceContains(slice []string, item string) bool { for _, v := range slice { if v == item { return true } } return false } func (am *AlertManager) SendTestNotification(c echo.Context) error { requestData := apis.RequestInfo(c) if requestData.AuthRecord == nil { return apis.NewForbiddenError("Forbidden", nil) } url := c.QueryParam("url") // log.Println("url", url) if url == "" { return c.JSON(200, map[string]string{"err": "URL is required"}) } err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppUrl, "View Beszel") if err != nil { return c.JSON(200, map[string]string{"err": err.Error()}) } return c.JSON(200, map[string]bool{"err": false}) }