diff --git a/hub/alerts.go b/hub/alerts.go new file mode 100644 index 0000000..c8da900 --- /dev/null +++ b/hub/alerts.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/mail" + + "github.com/pocketbase/dbx" + "github.com/pocketbase/pocketbase/models" + "github.com/pocketbase/pocketbase/tools/mailer" + "github.com/pocketbase/pocketbase/tools/types" +) + +func handleSystemAlerts(newStatus string, newRecord *models.Record, oldRecord *models.Record) { + alertRecords, err := app.Dao().FindRecordsByExpr("alerts", + dbx.NewExp("system = {:system}", dbx.Params{"system": oldRecord.Get("id")}), + ) + if err != nil || len(alertRecords) == 0 { + // log.Println("no alerts found for system") + return + } + // log.Println("found alerts", len(alertRecords)) + var systemInfo *SystemInfo + for _, alertRecord := range alertRecords { + name := alertRecord.Get("name").(string) + switch name { + case "Status": + handleStatusAlerts(newStatus, oldRecord, alertRecord) + case "CPU", "Memory", "Disk": + if newStatus != "up" { + continue + } + if systemInfo == nil { + systemInfo = getSystemInfo(newRecord) + } + if name == "CPU" { + handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.Cpu) + } else if name == "Memory" { + handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.MemPct) + } else if name == "Disk" { + handleSlidingValueAlert(newRecord, alertRecord, name, systemInfo.DiskPct) + } + } + } +} + +func getSystemInfo(record *models.Record) *SystemInfo { + var SystemInfo SystemInfo + json.Unmarshal([]byte(record.Get("info").(types.JsonRaw)), &SystemInfo) + return &SystemInfo +} + +func handleSlidingValueAlert(newRecord *models.Record, alertRecord *models.Record, name string, curValue float64) { + triggered := alertRecord.Get("triggered").(bool) + threshold := alertRecord.Get("value").(float64) + // fmt.Println(name, curValue, "threshold", threshold, "triggered", triggered) + var subject string + var body string + if !triggered && curValue > threshold { + alertRecord.Set("triggered", true) + systemName := newRecord.Get("name").(string) + subject = fmt.Sprintf("%s usage threshold exceeded on %s", name, systemName) + body = fmt.Sprintf("%s usage on %s is %.1f%%.\n\n- Beszel", name, systemName, curValue) + } else if triggered && curValue <= threshold { + alertRecord.Set("triggered", false) + systemName := newRecord.Get("name").(string) + subject = fmt.Sprintf("%s usage returned below threshold on %s", name, systemName) + body = fmt.Sprintf("%s usage on %s is below threshold at %.1f%%.\n\n%s\n\n- Beszel", name, systemName, curValue, app.Settings().Meta.AppUrl+"/system/"+systemName) + } else { + // fmt.Println(name, "not triggered") + return + } + if err := 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 := 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 { + sendAlert(EmailData{ + to: user.Get("email").(string), + subj: subject, + body: body, + }) + } +} + +func handleStatusAlerts(newStatus string, oldRecord *models.Record, alertRecord *models.Record) error { + var alertStatus string + switch newStatus { + case "up": + if oldRecord.Get("status") == "down" { + alertStatus = "up" + } + case "down": + if oldRecord.Get("status") == "up" { + alertStatus = "down" + } + } + if alertStatus == "" { + return nil + } + // expand the user relation + if errs := 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.Get("name").(string) + sendAlert(EmailData{ + to: user.Get("email").(string), + subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), + body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus), + }) + return nil +} + +func sendAlert(data EmailData) { + // fmt.Println("sending alert", "to", data.to, "subj", data.subj, "body", data.body) + message := &mailer.Message{ + From: mail.Address{ + Address: app.Settings().Meta.SenderAddress, + Name: app.Settings().Meta.SenderName, + }, + To: []mail.Address{{Address: data.to}}, + Subject: data.subj, + Text: data.body, + } + if err := app.NewMailClient().Send(message); err != nil { + app.Logger().Error("Failed to send alert: ", "err", err.Error()) + } +} diff --git a/hub/main.go b/hub/main.go index cffb738..ab65676 100644 --- a/hub/main.go +++ b/hub/main.go @@ -12,7 +12,6 @@ import ( "log" "net/http" "net/http/httputil" - "net/mail" "net/url" "os" "strings" @@ -26,7 +25,6 @@ import ( "github.com/pocketbase/pocketbase/models" "github.com/pocketbase/pocketbase/plugins/migratecmd" "github.com/pocketbase/pocketbase/tools/cron" - "github.com/pocketbase/pocketbase/tools/mailer" "github.com/spf13/cobra" "golang.org/x/crypto/ssh" ) @@ -175,7 +173,7 @@ func main() { } // alerts - handleStatusAlerts(newStatus, oldRecord) + handleSystemAlerts(newStatus, newRecord, oldRecord) return nil }) @@ -378,69 +376,6 @@ func requestJson(server *Server) (SystemData, error) { return systemData, nil } -func sendAlert(data EmailData) { - message := &mailer.Message{ - From: mail.Address{ - Address: app.Settings().Meta.SenderAddress, - Name: app.Settings().Meta.SenderName, - }, - To: []mail.Address{{Address: data.to}}, - Subject: data.subj, - Text: data.body, - } - if err := app.NewMailClient().Send(message); err != nil { - app.Logger().Error("Failed to send alert: ", "err", err.Error()) - } -} - -func handleStatusAlerts(newStatus string, oldRecord *models.Record) error { - var alertStatus string - switch newStatus { - case "up": - if oldRecord.Get("status") == "down" { - alertStatus = "up" - } - case "down": - if oldRecord.Get("status") == "up" { - alertStatus = "down" - } - } - if alertStatus == "" { - return nil - } - alerts, err := app.Dao().FindRecordsByFilter("alerts", "name = 'status' && system = {:system}", "-created", -1, 0, dbx.Params{ - "system": oldRecord.Get("id")}) - if err != nil { - log.Println("failed to get users", "err", err.Error()) - return nil - } - if len(alerts) == 0 { - return nil - } - // expand the user relation - if errs := app.Dao().ExpandRecords(alerts, []string{"user"}, nil); len(errs) > 0 { - return fmt.Errorf("failed to expand: %v", errs) - } - systemName := oldRecord.Get("name").(string) - emoji := "\U0001F534" - if alertStatus == "up" { - emoji = "\u2705" - } - for _, alert := range alerts { - user := alert.ExpandedOne("user") - if user == nil { - continue - } - // send alert - sendAlert(EmailData{ - to: user.Get("email").(string), - subj: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji), - body: fmt.Sprintf("Connection to %s is %s\n\n- Beszel", systemName, alertStatus), - }) - } - return nil -} - func getSSHKey() ([]byte, error) { dataDir := app.DataDir() // check if the key pair already exists diff --git a/hub/migrations/1720568457_collections_snapshot.go b/hub/migrations/1720568457_collections_snapshot.go index 131d348..047c16c 100644 --- a/hub/migrations/1720568457_collections_snapshot.go +++ b/hub/migrations/1720568457_collections_snapshot.go @@ -15,7 +15,7 @@ func init() { { "id": "2hz5ncl8tizk5nx", "created": "2024-07-07 16:08:20.979Z", - "updated": "2024-07-17 15:27:00.429Z", + "updated": "2024-07-22 19:39:17.434Z", "name": "systems", "type": "base", "system": false, @@ -102,7 +102,7 @@ func init() { "unique": false, "options": { "collectionId": "_pb_users_auth_", - "cascadeDelete": false, + "cascadeDelete": true, "minSelect": null, "maxSelect": null, "displayFields": null @@ -250,7 +250,7 @@ func init() { { "id": "_pb_users_auth_", "created": "2024-07-14 16:25:18.226Z", - "updated": "2024-07-20 00:55:02.071Z", + "updated": "2024-07-22 20:10:20.670Z", "name": "users", "type": "auth", "system": false, @@ -304,7 +304,7 @@ func init() { "options": { "allowEmailAuth": true, "allowOAuth2Auth": true, - "allowUsernameAuth": true, + "allowUsernameAuth": false, "exceptEmailDomains": null, "manageRule": null, "minPasswordLength": 8, @@ -316,7 +316,7 @@ func init() { { "id": "elngm8x1l60zi2v", "created": "2024-07-15 01:16:04.044Z", - "updated": "2024-07-15 22:44:12.297Z", + "updated": "2024-07-22 19:13:16.498Z", "name": "alerts", "type": "base", "system": false, @@ -364,16 +364,43 @@ func init() { "options": { "maxSelect": 1, "values": [ - "status" + "Status", + "CPU", + "Memory", + "Disk" ] } + }, + { + "system": false, + "id": "o2ablxvn", + "name": "value", + "type": "number", + "required": false, + "presentable": false, + "unique": false, + "options": { + "min": null, + "max": null, + "noDecimal": false + } + }, + { + "system": false, + "id": "6hgdf6hs", + "name": "triggered", + "type": "bool", + "required": false, + "presentable": false, + "unique": false, + "options": {} } ], "indexes": [], "listRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "viewRule": "", "createRule": "@request.auth.id != \"\" && user.id = @request.auth.id", - "updateRule": null, + "updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id", "options": {} } diff --git a/hub/site/bun.lockb b/hub/site/bun.lockb index 7f1670c..ad7893f 100755 Binary files a/hub/site/bun.lockb and b/hub/site/bun.lockb differ diff --git a/hub/site/package.json b/hub/site/package.json index d81f089..d8fe2b7 100644 --- a/hub/site/package.json +++ b/hub/site/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-separator": "^1.1.0", + "@radix-ui/react-slider": "^1.2.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", diff --git a/hub/site/src/components/table-alerts.tsx b/hub/site/src/components/table-alerts.tsx index 376697f..0b5ffbf 100644 --- a/hub/site/src/components/table-alerts.tsx +++ b/hub/site/src/components/table-alerts.tsx @@ -13,9 +13,18 @@ import { cn, isAdmin } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Switch } from '@/components/ui/switch' import { AlertRecord, SystemRecord } from '@/types' -import { useMemo, useState } from 'react' +import { lazy, Suspense, useMemo, useState } from 'react' import { toast } from './ui/use-toast' +const Slider = lazy(() => import('./ui/slider')) + +const failedUpdateToast = () => + toast({ + title: 'Failed to update alert', + description: 'Please check logs for more details.', + variant: 'destructive', + }) + export default function AlertsButton({ system }: { system: SystemRecord }) { const alerts = useStore($alerts) @@ -38,7 +47,7 @@ export default function AlertsButton({ system }: { system: SystemRecord }) { /> - + Alerts for {system.name} @@ -54,38 +63,57 @@ export default function AlertsButton({ system }: { system: SystemRecord }) { to ensure alerts are delivered.{' '} )} - Webhook delivery and more alert options will be added in the future. - +
+ + + + +
) } -function Alert({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) { +function AlertStatus({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[] }) { const [pendingChange, setPendingChange] = useState(false) const alert = useMemo(() => { - return alerts.find((alert) => alert.name === 'status') + return alerts.find((alert) => alert.name === 'Status') }, [alerts]) return (