alerts for cpu, memory, and disk

This commit is contained in:
Henry Dollman
2024-07-22 16:14:55 -04:00
parent b1d994a0ff
commit c060e294f9
8 changed files with 335 additions and 93 deletions

142
hub/alerts.go Normal file
View File

@@ -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())
}
}

View File

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

View File

@@ -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": {}
}

Binary file not shown.

View File

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

View File

@@ -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 }) {
/>
</Button>
</DialogTrigger>
<DialogContent>
<DialogContent className="max-h-full overflow-auto">
<DialogHeader>
<DialogTitle className="mb-1">Alerts for {system.name}</DialogTitle>
<DialogDescription>
@@ -54,38 +63,57 @@ export default function AlertsButton({ system }: { system: SystemRecord }) {
to ensure alerts are delivered.{' '}
</span>
)}
Webhook delivery and more alert options will be added in the future.
</DialogDescription>
</DialogHeader>
<Alert system={system} alerts={systemAlerts} />
<div className="grid gap-3">
<AlertStatus system={system} alerts={systemAlerts} />
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="CPU"
title="CPU usage"
description="Triggers when CPU usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Memory"
title="Memory usage"
description="Triggers when memory usage exceeds a threshold."
/>
<AlertWithSlider
system={system}
alerts={systemAlerts}
name="Disk"
title="Disk usage"
description="Triggers when disk usage exceeds a threshold."
/>
</div>
</DialogContent>
</Dialog>
)
}
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 (
<label
htmlFor="status"
htmlFor="alert-status"
className="space-y-2 flex flex-row items-center justify-between rounded-lg border p-4 cursor-pointer"
>
<div className="grid gap-0.5 select-none">
<p className="font-medium text-base">System status</p>
<span
id=":r3m:-form-item-description"
className="block text-[0.8rem] text-foreground opacity-80"
>
<p className="font-medium text-[1.05em]">System status</p>
<span className="block text-[0.85em] text-foreground opacity-80">
Triggers when status switches between up and down.
</span>
</div>
<Switch
id="status"
id="alert-status"
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
@@ -101,15 +129,11 @@ function Alert({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[]
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name: 'status',
name: 'Status',
})
}
} catch (e) {
toast({
title: 'Failed to update alert',
description: 'Please check logs for more details.',
variant: 'destructive',
})
failedUpdateToast()
} finally {
setPendingChange(false)
}
@@ -118,3 +142,93 @@ function Alert({ system, alerts }: { system: SystemRecord; alerts: AlertRecord[]
</label>
)
}
function AlertWithSlider({
system,
alerts,
name,
title,
description,
}: {
system: SystemRecord
alerts: AlertRecord[]
name: string
title: string
description: string
}) {
const [pendingChange, setPendingChange] = useState(false)
const [liveValue, setLiveValue] = useState(50)
const alert = useMemo(() => {
const alert = alerts.find((alert) => alert.name === name)
if (alert) {
setLiveValue(alert.value)
}
return alert
}, [alerts])
return (
<div className="rounded-lg border">
<label
htmlFor={`alert-${name}`}
className={cn('space-y-2 flex flex-row items-center justify-between cursor-pointer p-4', {
'pb-0': !!alert,
})}
>
<div className="grid gap-0.5 select-none">
<p className="font-medium text-[1.05em]">{title}</p>
<span className="block text-[0.85em] text-foreground opacity-80">{description}</span>
</div>
<Switch
id={`alert-${name}`}
className={cn('transition-opacity', pendingChange && 'opacity-40')}
checked={!!alert}
value={!!alert ? 'on' : 'off'}
onCheckedChange={async (active) => {
if (pendingChange) {
return
}
setPendingChange(true)
try {
if (!active && alert) {
await pb.collection('alerts').delete(alert.id)
} else if (active) {
pb.collection('alerts').create({
system: system.id,
user: pb.authStore.model!.id,
name,
value: liveValue,
})
}
} catch (e) {
failedUpdateToast()
} finally {
setPendingChange(false)
}
}}
/>
</label>
{alert && (
<div className="flex mt-2 mb-3 gap-3 px-4">
<Suspense>
<Slider
defaultValue={[liveValue]}
onValueCommit={(val) => {
pb.collection('alerts').update(alert.id, {
value: val[0],
})
}}
onValueChange={(val) => {
setLiveValue(val[0])
}}
min={10}
max={99}
// step={1}
/>
</Suspense>
<span className="tabular-nums tracking-tighter text-[.92em]">{liveValue}%</span>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,23 @@
import * as React from 'react'
import * as SliderPrimitive from '@radix-ui/react-slider'
import { cn } from '@/lib/utils'
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn('relative flex w-full touch-none select-none items-center', className)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export default Slider

View File

@@ -45,7 +45,7 @@ export const updateSystemList = async () => {
export const updateAlerts = () => {
pb.collection('alerts')
.getFullList<AlertRecord>({ fields: 'id,name,system' })
.getFullList<AlertRecord>({ fields: 'id,name,system,value' })
.then((records) => {
$alerts.set(records)
})