mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-22 21:46:18 +01:00
Compare commits
108 Commits
feat-allow
...
7fee3da2e8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fee3da2e8 | ||
|
|
443e203d9c | ||
|
|
24875b5733 | ||
|
|
66eaf45c79 | ||
|
|
2b9fb8b1ec | ||
|
|
06e0ae4733 | ||
|
|
4cc0bfeb0f | ||
|
|
320d0e5d97 | ||
|
|
67953bb6af | ||
|
|
123c57ded9 | ||
|
|
a7991ca184 | ||
|
|
1b01410533 | ||
|
|
1687919f31 | ||
|
|
6046dbb727 | ||
|
|
2505b16faa | ||
|
|
c4352e65fb | ||
|
|
7b2e9ccdcb | ||
|
|
75a87cddbf | ||
|
|
bda7b9b48d | ||
|
|
65d6ec1918 | ||
|
|
0e16fca07f | ||
|
|
b7e574f379 | ||
|
|
113f1833e1 | ||
|
|
a0d3e60d29 | ||
|
|
df7f12bfec | ||
|
|
02e88d99e5 | ||
|
|
a11ceb36d1 | ||
|
|
d3a373338d | ||
|
|
e1ef05da8c | ||
|
|
c01bfd9332 | ||
|
|
209e82aed2 | ||
|
|
c66e740d52 | ||
|
|
551ae49c2e | ||
|
|
906daa6f2a | ||
|
|
49e55943c1 | ||
|
|
9e75cbc1ef | ||
|
|
79190a2c51 | ||
|
|
32960c7c35 | ||
|
|
cdff974a8a | ||
|
|
20a6ef129c | ||
|
|
d1e5310f83 | ||
|
|
152173eab5 | ||
|
|
c2aec69638 | ||
|
|
16c97fe77b | ||
|
|
bed5bc2791 | ||
|
|
424c7aceff | ||
|
|
8eb101e714 | ||
|
|
f2e69e2a80 | ||
|
|
41b4dbce98 | ||
|
|
c3432fc7b5 | ||
|
|
b167244e28 | ||
|
|
bcca6a5a9d | ||
|
|
6b661b4878 | ||
|
|
bc537edb73 | ||
|
|
561a3e8aaf | ||
|
|
f173ea37da | ||
|
|
0d9f2ba06a | ||
|
|
4e772e6008 | ||
|
|
b959d97502 | ||
|
|
735cbe2cf0 | ||
|
|
0f5a470495 | ||
|
|
a42e99370e | ||
|
|
abfae78af1 | ||
|
|
e36c40c4a9 | ||
|
|
bf7b2ae598 | ||
|
|
f71ee4b058 | ||
|
|
f2466eb37d | ||
|
|
7244c7130b | ||
|
|
f2e84a9d3e | ||
|
|
04a1ee5e4e | ||
|
|
9b83088897 | ||
|
|
13b30aa255 | ||
|
|
2bd25e9e8d | ||
|
|
4fe192bf28 | ||
|
|
2056ae285f | ||
|
|
a02e7a0a69 | ||
|
|
b1a9e90034 | ||
|
|
20916fab3e | ||
|
|
0c777cca72 | ||
|
|
44e30ad429 | ||
|
|
9e30786dda | ||
|
|
1f677773e7 | ||
|
|
882289da91 | ||
|
|
fbbc4eff27 | ||
|
|
b78231f677 | ||
|
|
332e2d14a9 | ||
|
|
e088b88c84 | ||
|
|
09840f95d9 | ||
|
|
de03bb658a | ||
|
|
bcadfeb729 | ||
|
|
ec582a9171 | ||
|
|
0c3eaefc90 | ||
|
|
d6b5866f90 | ||
|
|
2ba629e4b4 | ||
|
|
6e9341b7ff | ||
|
|
89499a341b | ||
|
|
9c88845798 | ||
|
|
c9f65f63e6 | ||
|
|
996620c8e0 | ||
|
|
626c1358d9 | ||
|
|
19dd39b7db | ||
|
|
d482b50e31 | ||
|
|
f61ec49c76 | ||
|
|
2b43ba3cbe | ||
|
|
b2b1a0b6ea | ||
|
|
b11d0aae61 | ||
|
|
2b73d8845a | ||
|
|
41e3e3d760 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -14,4 +14,5 @@ node_modules
|
||||
beszel/build
|
||||
*timestamp*
|
||||
.swc
|
||||
beszel/site/src/locales/**/*.ts
|
||||
beszel/site/src/locales/**/*.ts
|
||||
*.bak
|
||||
@@ -2,25 +2,24 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/containrrr/shoutrrr"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
type AlertManager struct {
|
||||
app core.App
|
||||
app core.App
|
||||
alertQueue chan alertTask
|
||||
stopChan chan struct{}
|
||||
pendingAlerts sync.Map
|
||||
}
|
||||
|
||||
type AlertMessageData struct {
|
||||
@@ -60,350 +59,43 @@ type SystemAlertData struct {
|
||||
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
|
||||
}
|
||||
|
||||
// notification services that support title param
|
||||
var supportsTitle = map[string]struct{}{
|
||||
"bark": {},
|
||||
"discord": {},
|
||||
"gotify": {},
|
||||
"ifttt": {},
|
||||
"join": {},
|
||||
"matrix": {},
|
||||
"ntfy": {},
|
||||
"opsgenie": {},
|
||||
"pushbullet": {},
|
||||
"pushover": {},
|
||||
"slack": {},
|
||||
"teams": {},
|
||||
"telegram": {},
|
||||
"zulip": {},
|
||||
}
|
||||
|
||||
// NewAlertManager creates a new AlertManager instance.
|
||||
func NewAlertManager(app core.App) *AlertManager {
|
||||
return &AlertManager{
|
||||
app: app,
|
||||
am := &AlertManager{
|
||||
app: app,
|
||||
alertQueue: make(chan alertTask),
|
||||
stopChan: make(chan struct{}),
|
||||
}
|
||||
go am.startWorker()
|
||||
return am
|
||||
}
|
||||
|
||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
||||
// start := time.Now()
|
||||
// defer func() {
|
||||
// log.Println("alert stats took", time.Since(start))
|
||||
// }()
|
||||
alertRecords, err := am.app.FindAllRecords("alerts",
|
||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
||||
)
|
||||
if err != nil || len(alertRecords) == 0 {
|
||||
// log.Println("no alerts found for system")
|
||||
return nil
|
||||
}
|
||||
|
||||
var validAlerts []SystemAlertData
|
||||
now := systemRecord.GetDateTime("updated").Time().UTC()
|
||||
oldestTime := now
|
||||
|
||||
for _, alertRecord := range alertRecords {
|
||||
name := alertRecord.GetString("name")
|
||||
var val float64
|
||||
unit := "%"
|
||||
|
||||
switch name {
|
||||
case "CPU":
|
||||
val = systemInfo.Cpu
|
||||
case "Memory":
|
||||
val = systemInfo.MemPct
|
||||
case "Bandwidth":
|
||||
val = systemInfo.Bandwidth
|
||||
unit = " MB/s"
|
||||
case "Disk":
|
||||
maxUsedPct := systemInfo.DiskPct
|
||||
for _, fs := range extraFs {
|
||||
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
||||
if usedPct > maxUsedPct {
|
||||
maxUsedPct = usedPct
|
||||
}
|
||||
}
|
||||
val = maxUsedPct
|
||||
case "Temperature":
|
||||
if temperatures == nil {
|
||||
continue
|
||||
}
|
||||
for _, temp := range temperatures {
|
||||
if temp > val {
|
||||
val = temp
|
||||
}
|
||||
}
|
||||
unit = "°C"
|
||||
}
|
||||
|
||||
triggered := alertRecord.GetBool("triggered")
|
||||
threshold := alertRecord.GetFloat("value")
|
||||
|
||||
// CONTINUE
|
||||
// IF alert is not triggered and curValue is less than threshold
|
||||
// OR alert is triggered and curValue is greater than threshold
|
||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||
continue
|
||||
}
|
||||
|
||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||
// add time to alert time to make sure it's slighty after record creation
|
||||
time := now.Add(-time.Duration(min) * time.Minute)
|
||||
if time.Before(oldestTime) {
|
||||
oldestTime = time
|
||||
}
|
||||
|
||||
validAlerts = append(validAlerts, SystemAlertData{
|
||||
systemRecord: systemRecord,
|
||||
alertRecord: alertRecord,
|
||||
name: name,
|
||||
unit: unit,
|
||||
val: val,
|
||||
threshold: threshold,
|
||||
triggered: triggered,
|
||||
time: time,
|
||||
min: min,
|
||||
})
|
||||
}
|
||||
|
||||
systemStats := []struct {
|
||||
Stats []byte `db:"stats"`
|
||||
Created types.DateTime `db:"created"`
|
||||
}{}
|
||||
|
||||
err = am.app.DB().
|
||||
Select("stats", "created").
|
||||
From("system_stats").
|
||||
Where(dbx.NewExp(
|
||||
"system={:system} AND type='1m' AND created > {:created}",
|
||||
dbx.Params{
|
||||
"system": systemRecord.Id,
|
||||
// subtract some time to give us a bit of buffer
|
||||
"created": oldestTime.Add(-time.Second * 90),
|
||||
},
|
||||
)).
|
||||
OrderBy("created").
|
||||
All(&systemStats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get oldest record creation time from first record in the slice
|
||||
oldestRecordTime := systemStats[0].Created.Time()
|
||||
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
||||
|
||||
// delete from validAlerts if time is older than oldestRecord
|
||||
for i := 0; i < len(validAlerts); i++ {
|
||||
if validAlerts[i].time.Before(oldestRecordTime) {
|
||||
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
|
||||
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAlerts) == 0 {
|
||||
// log.Println("no valid alerts found")
|
||||
return nil
|
||||
}
|
||||
|
||||
var stats SystemAlertStats
|
||||
|
||||
// we can skip the latest systemStats record since it's the current value
|
||||
for i := 0; i < len(systemStats); i++ {
|
||||
stat := systemStats[i]
|
||||
// subtract 10 seconds to give a small time buffer
|
||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||
return err
|
||||
}
|
||||
// log.Println("stats", stats)
|
||||
for j := range validAlerts {
|
||||
alert := &validAlerts[j]
|
||||
// reset alert val on first iteration
|
||||
if i == 0 {
|
||||
alert.val = 0
|
||||
}
|
||||
// continue if system_stats is older than alert time range
|
||||
if systemStatsCreation.Before(alert.time) {
|
||||
continue
|
||||
}
|
||||
// add to alert value
|
||||
switch alert.name {
|
||||
case "CPU":
|
||||
alert.val += stats.Cpu
|
||||
case "Memory":
|
||||
alert.val += stats.Mem
|
||||
case "Bandwidth":
|
||||
alert.val += stats.NetSent + stats.NetRecv
|
||||
case "Disk":
|
||||
if alert.mapSums == nil {
|
||||
alert.mapSums = make(map[string]float32, len(extraFs)+1)
|
||||
}
|
||||
// add root disk
|
||||
if _, ok := alert.mapSums["root"]; !ok {
|
||||
alert.mapSums["root"] = 0.0
|
||||
}
|
||||
alert.mapSums["root"] += float32(stats.Disk)
|
||||
// add extra disks
|
||||
for key, fs := range extraFs {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = 0.0
|
||||
}
|
||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||
}
|
||||
case "Temperature":
|
||||
if alert.mapSums == nil {
|
||||
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
||||
}
|
||||
for key, temp := range stats.Temperatures {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = float32(0)
|
||||
}
|
||||
alert.mapSums[key] += temp
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
alert.count++
|
||||
}
|
||||
}
|
||||
// sum up vals for each alert
|
||||
for _, alert := range validAlerts {
|
||||
switch alert.name {
|
||||
case "Disk":
|
||||
maxPct := float32(0)
|
||||
for key, value := range alert.mapSums {
|
||||
sumPct := float32(value)
|
||||
if sumPct > maxPct {
|
||||
maxPct = sumPct
|
||||
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
||||
}
|
||||
}
|
||||
alert.val = float64(maxPct / float32(alert.count))
|
||||
case "Temperature":
|
||||
maxTemp := float32(0)
|
||||
for key, value := range alert.mapSums {
|
||||
sumTemp := float32(value) / float32(alert.count)
|
||||
if sumTemp > maxTemp {
|
||||
maxTemp = sumTemp
|
||||
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
||||
}
|
||||
}
|
||||
alert.val = float64(maxTemp)
|
||||
default:
|
||||
alert.val = alert.val / float64(alert.count)
|
||||
}
|
||||
minCount := float32(alert.min) / 1.2
|
||||
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||
// pass through alert if count is greater than or equal to minCount
|
||||
if float32(alert.count) >= minCount {
|
||||
if !alert.triggered && alert.val > alert.threshold {
|
||||
alert.triggered = true
|
||||
go am.sendSystemAlert(alert)
|
||||
} else if alert.triggered && alert.val <= alert.threshold {
|
||||
alert.triggered = false
|
||||
go am.sendSystemAlert(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||
systemName := alert.systemRecord.GetString("name")
|
||||
|
||||
// change Disk to Disk usage
|
||||
if alert.name == "Disk" {
|
||||
alert.name += " usage"
|
||||
}
|
||||
|
||||
// make title alert name lowercase if not CPU
|
||||
titleAlertName := alert.name
|
||||
if titleAlertName != "CPU" {
|
||||
titleAlertName = strings.ToLower(titleAlertName)
|
||||
}
|
||||
|
||||
var subject string
|
||||
if alert.triggered {
|
||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||
} else {
|
||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||
}
|
||||
minutesLabel := "minute"
|
||||
if alert.min > 1 {
|
||||
minutesLabel += "s"
|
||||
}
|
||||
if alert.descriptor == "" {
|
||||
alert.descriptor = alert.name
|
||||
}
|
||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||
|
||||
alert.alertRecord.Set("triggered", alert.triggered)
|
||||
if err := am.app.Save(alert.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.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||
return
|
||||
}
|
||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
||||
am.sendAlert(AlertMessageData{
|
||||
UserID: user.Id,
|
||||
Title: subject,
|
||||
Message: body,
|
||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||
LinkText: "View " + systemName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// todo: allow x minutes downtime before sending alert
|
||||
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
|
||||
var alertStatus string
|
||||
switch newStatus {
|
||||
case "up":
|
||||
if oldSystemRecord.GetString("status") == "down" {
|
||||
alertStatus = "up"
|
||||
}
|
||||
case "down":
|
||||
if oldSystemRecord.GetString("status") == "up" {
|
||||
alertStatus = "down"
|
||||
}
|
||||
}
|
||||
if alertStatus == "" {
|
||||
return nil
|
||||
}
|
||||
// check if use
|
||||
alertRecords, err := am.app.FindAllRecords("alerts",
|
||||
dbx.HashExp{
|
||||
"system": oldSystemRecord.Id,
|
||||
"name": "Status",
|
||||
},
|
||||
)
|
||||
if err != nil || len(alertRecords) == 0 {
|
||||
// log.Println("no alerts found for system")
|
||||
return nil
|
||||
}
|
||||
for _, alertRecord := range alertRecords {
|
||||
// expand the user relation
|
||||
if errs := am.app.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 := oldSystemRecord.GetString("name")
|
||||
am.sendAlert(AlertMessageData{
|
||||
UserID: user.Id,
|
||||
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.PathEscape(systemName),
|
||||
LinkText: "View " + systemName,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *AlertManager) sendAlert(data AlertMessageData) {
|
||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||
// get user settings
|
||||
record, err := am.app.FindFirstRecordByFilter(
|
||||
"user_settings", "user={:user}",
|
||||
dbx.Params{"user": data.UserID},
|
||||
)
|
||||
if err != nil {
|
||||
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
|
||||
return
|
||||
return err
|
||||
}
|
||||
// unmarshal user settings
|
||||
userAlertSettings := UserNotificationSettings{
|
||||
@@ -421,8 +113,7 @@ func (am *AlertManager) sendAlert(data AlertMessageData) {
|
||||
}
|
||||
// send alerts via email
|
||||
if len(userAlertSettings.Emails) == 0 {
|
||||
// log.Println("No email addresses found")
|
||||
return
|
||||
return nil
|
||||
}
|
||||
addresses := []mail.Address{}
|
||||
for _, email := range userAlertSettings.Emails {
|
||||
@@ -437,18 +128,16 @@ func (am *AlertManager) sendAlert(data AlertMessageData) {
|
||||
Name: am.app.Settings().Meta.SenderName,
|
||||
},
|
||||
}
|
||||
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)
|
||||
err = am.app.NewMailClient().Send(&message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||
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 {
|
||||
@@ -458,7 +147,7 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
||||
queryParams := parsedURL.Query()
|
||||
|
||||
// Add title
|
||||
if sliceContains(supportsTitle, scheme) {
|
||||
if _, ok := supportsTitle[scheme]; ok {
|
||||
queryParams.Add("title", title)
|
||||
} else if scheme == "mattermost" {
|
||||
// use markdown title for mattermost
|
||||
@@ -499,16 +188,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
||||
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(e *core.RequestEvent) error {
|
||||
info, _ := e.RequestInfo()
|
||||
if info.Auth == nil {
|
||||
|
||||
175
beszel/internal/alerts/alerts_status.go
Normal file
175
beszel/internal/alerts/alerts_status.go
Normal file
@@ -0,0 +1,175 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
type alertTask struct {
|
||||
action string // "schedule" or "cancel"
|
||||
systemName string
|
||||
alertRecord *core.Record
|
||||
delay time.Duration
|
||||
}
|
||||
|
||||
type alertInfo struct {
|
||||
systemName string
|
||||
alertRecord *core.Record
|
||||
expireTime time.Time
|
||||
}
|
||||
|
||||
// startWorker is a long-running goroutine that processes alert tasks
|
||||
// every x seconds. It must be running to process status alerts.
|
||||
func (am *AlertManager) startWorker() {
|
||||
// no special reason for 13 seconds
|
||||
tick := time.Tick(13 * time.Second)
|
||||
for {
|
||||
select {
|
||||
case <-am.stopChan:
|
||||
return
|
||||
case task := <-am.alertQueue:
|
||||
switch task.action {
|
||||
case "schedule":
|
||||
am.pendingAlerts.Store(task.alertRecord.Id, &alertInfo{
|
||||
systemName: task.systemName,
|
||||
alertRecord: task.alertRecord,
|
||||
expireTime: time.Now().Add(task.delay),
|
||||
})
|
||||
case "cancel":
|
||||
am.pendingAlerts.Delete(task.alertRecord.Id)
|
||||
}
|
||||
case <-tick:
|
||||
// Check for expired alerts every tick
|
||||
now := time.Now()
|
||||
for key, value := range am.pendingAlerts.Range {
|
||||
info := value.(*alertInfo)
|
||||
if now.After(info.expireTime) {
|
||||
// Downtime delay has passed, process alert
|
||||
am.sendStatusAlert("down", info.systemName, info.alertRecord)
|
||||
am.pendingAlerts.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StopWorker shuts down the AlertManager.worker goroutine
|
||||
func (am *AlertManager) StopWorker() {
|
||||
close(am.stopChan)
|
||||
}
|
||||
|
||||
// HandleStatusAlerts manages the logic when system status changes.
|
||||
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
|
||||
switch newStatus {
|
||||
case "up":
|
||||
if oldSystemRecord.GetString("status") != "down" {
|
||||
return nil
|
||||
}
|
||||
case "down":
|
||||
if oldSystemRecord.GetString("status") != "up" {
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
alertRecords, err := am.getSystemStatusAlerts(oldSystemRecord.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(alertRecords) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
systemName := oldSystemRecord.GetString("name")
|
||||
if newStatus == "down" {
|
||||
am.handleSystemDown(systemName, alertRecords)
|
||||
} else {
|
||||
am.handleSystemUp(systemName, alertRecords)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
||||
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
||||
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
|
||||
"system": systemID,
|
||||
"name": "Status",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return alertRecords, nil
|
||||
}
|
||||
|
||||
// Schedules delayed "down" alerts for each alert record.
|
||||
func (am *AlertManager) handleSystemDown(systemName string, alertRecords []*core.Record) {
|
||||
for _, alertRecord := range alertRecords {
|
||||
// Continue if alert is already scheduled
|
||||
if _, exists := am.pendingAlerts.Load(alertRecord.Id); exists {
|
||||
continue
|
||||
}
|
||||
// Schedule by adding to queue
|
||||
min := max(1, alertRecord.GetInt("min"))
|
||||
am.alertQueue <- alertTask{
|
||||
action: "schedule",
|
||||
systemName: systemName,
|
||||
alertRecord: alertRecord,
|
||||
delay: time.Duration(min) * time.Minute,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleSystemUp manages the logic when a system status changes to "up".
|
||||
// It cancels any pending alerts and sends "up" alerts.
|
||||
func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.Record) {
|
||||
for _, alertRecord := range alertRecords {
|
||||
alertRecordID := alertRecord.Id
|
||||
// If alert exists for record, delete and continue (down alert not sent)
|
||||
if _, exists := am.pendingAlerts.Load(alertRecordID); exists {
|
||||
am.alertQueue <- alertTask{
|
||||
action: "cancel",
|
||||
alertRecord: alertRecord,
|
||||
}
|
||||
continue
|
||||
}
|
||||
// No alert scheduled for this record, send "up" alert
|
||||
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
||||
am.app.Logger().Error("Failed to send alert", "err", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
||||
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
||||
var emoji string
|
||||
if alertStatus == "up" {
|
||||
emoji = "\u2705" // Green checkmark emoji
|
||||
} else {
|
||||
emoji = "\U0001F534" // Red alert emoji
|
||||
}
|
||||
|
||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||
message := strings.TrimSuffix(title, emoji)
|
||||
|
||||
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||
return errs["user"]
|
||||
}
|
||||
user := alertRecord.ExpandedOne("user")
|
||||
if user == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return am.SendAlert(AlertMessageData{
|
||||
UserID: user.Id,
|
||||
Title: title,
|
||||
Message: message,
|
||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||
LinkText: "View " + systemName,
|
||||
})
|
||||
}
|
||||
288
beszel/internal/alerts/alerts_system.go
Normal file
288
beszel/internal/alerts/alerts_system.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"beszel/internal/entities/system"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/spf13/cast"
|
||||
)
|
||||
|
||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
||||
alertRecords, err := am.app.FindAllRecords("alerts",
|
||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
||||
)
|
||||
if err != nil || len(alertRecords) == 0 {
|
||||
// log.Println("no alerts found for system")
|
||||
return nil
|
||||
}
|
||||
|
||||
var validAlerts []SystemAlertData
|
||||
now := systemRecord.GetDateTime("updated").Time().UTC()
|
||||
oldestTime := now
|
||||
|
||||
for _, alertRecord := range alertRecords {
|
||||
name := alertRecord.GetString("name")
|
||||
var val float64
|
||||
unit := "%"
|
||||
|
||||
switch name {
|
||||
case "CPU":
|
||||
val = systemInfo.Cpu
|
||||
case "Memory":
|
||||
val = systemInfo.MemPct
|
||||
case "Bandwidth":
|
||||
val = systemInfo.Bandwidth
|
||||
unit = " MB/s"
|
||||
case "Disk":
|
||||
maxUsedPct := systemInfo.DiskPct
|
||||
for _, fs := range extraFs {
|
||||
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
||||
if usedPct > maxUsedPct {
|
||||
maxUsedPct = usedPct
|
||||
}
|
||||
}
|
||||
val = maxUsedPct
|
||||
case "Temperature":
|
||||
if temperatures == nil {
|
||||
continue
|
||||
}
|
||||
for _, temp := range temperatures {
|
||||
if temp > val {
|
||||
val = temp
|
||||
}
|
||||
}
|
||||
unit = "°C"
|
||||
}
|
||||
|
||||
triggered := alertRecord.GetBool("triggered")
|
||||
threshold := alertRecord.GetFloat("value")
|
||||
|
||||
// CONTINUE
|
||||
// IF alert is not triggered and curValue is less than threshold
|
||||
// OR alert is triggered and curValue is greater than threshold
|
||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||
continue
|
||||
}
|
||||
|
||||
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||
// add time to alert time to make sure it's slighty after record creation
|
||||
time := now.Add(-time.Duration(min) * time.Minute)
|
||||
if time.Before(oldestTime) {
|
||||
oldestTime = time
|
||||
}
|
||||
|
||||
validAlerts = append(validAlerts, SystemAlertData{
|
||||
systemRecord: systemRecord,
|
||||
alertRecord: alertRecord,
|
||||
name: name,
|
||||
unit: unit,
|
||||
val: val,
|
||||
threshold: threshold,
|
||||
triggered: triggered,
|
||||
time: time,
|
||||
min: min,
|
||||
})
|
||||
}
|
||||
|
||||
systemStats := []struct {
|
||||
Stats []byte `db:"stats"`
|
||||
Created types.DateTime `db:"created"`
|
||||
}{}
|
||||
|
||||
err = am.app.DB().
|
||||
Select("stats", "created").
|
||||
From("system_stats").
|
||||
Where(dbx.NewExp(
|
||||
"system={:system} AND type='1m' AND created > {:created}",
|
||||
dbx.Params{
|
||||
"system": systemRecord.Id,
|
||||
// subtract some time to give us a bit of buffer
|
||||
"created": oldestTime.Add(-time.Second * 90),
|
||||
},
|
||||
)).
|
||||
OrderBy("created").
|
||||
All(&systemStats)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// get oldest record creation time from first record in the slice
|
||||
oldestRecordTime := systemStats[0].Created.Time()
|
||||
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
||||
|
||||
// delete from validAlerts if time is older than oldestRecord
|
||||
for i := range validAlerts {
|
||||
if validAlerts[i].time.Before(oldestRecordTime) {
|
||||
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
|
||||
validAlerts = slices.Delete(validAlerts, i, i+1)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validAlerts) == 0 {
|
||||
// log.Println("no valid alerts found")
|
||||
return nil
|
||||
}
|
||||
|
||||
var stats SystemAlertStats
|
||||
|
||||
// we can skip the latest systemStats record since it's the current value
|
||||
for i := range systemStats {
|
||||
stat := systemStats[i]
|
||||
// subtract 10 seconds to give a small time buffer
|
||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||
return err
|
||||
}
|
||||
// log.Println("stats", stats)
|
||||
for j := range validAlerts {
|
||||
alert := &validAlerts[j]
|
||||
// reset alert val on first iteration
|
||||
if i == 0 {
|
||||
alert.val = 0
|
||||
}
|
||||
// continue if system_stats is older than alert time range
|
||||
if systemStatsCreation.Before(alert.time) {
|
||||
continue
|
||||
}
|
||||
// add to alert value
|
||||
switch alert.name {
|
||||
case "CPU":
|
||||
alert.val += stats.Cpu
|
||||
case "Memory":
|
||||
alert.val += stats.Mem
|
||||
case "Bandwidth":
|
||||
alert.val += stats.NetSent + stats.NetRecv
|
||||
case "Disk":
|
||||
if alert.mapSums == nil {
|
||||
alert.mapSums = make(map[string]float32, len(extraFs)+1)
|
||||
}
|
||||
// add root disk
|
||||
if _, ok := alert.mapSums["root"]; !ok {
|
||||
alert.mapSums["root"] = 0.0
|
||||
}
|
||||
alert.mapSums["root"] += float32(stats.Disk)
|
||||
// add extra disks
|
||||
for key, fs := range extraFs {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = 0.0
|
||||
}
|
||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||
}
|
||||
case "Temperature":
|
||||
if alert.mapSums == nil {
|
||||
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
||||
}
|
||||
for key, temp := range stats.Temperatures {
|
||||
if _, ok := alert.mapSums[key]; !ok {
|
||||
alert.mapSums[key] = float32(0)
|
||||
}
|
||||
alert.mapSums[key] += temp
|
||||
}
|
||||
default:
|
||||
continue
|
||||
}
|
||||
alert.count++
|
||||
}
|
||||
}
|
||||
// sum up vals for each alert
|
||||
for _, alert := range validAlerts {
|
||||
switch alert.name {
|
||||
case "Disk":
|
||||
maxPct := float32(0)
|
||||
for key, value := range alert.mapSums {
|
||||
sumPct := float32(value)
|
||||
if sumPct > maxPct {
|
||||
maxPct = sumPct
|
||||
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
||||
}
|
||||
}
|
||||
alert.val = float64(maxPct / float32(alert.count))
|
||||
case "Temperature":
|
||||
maxTemp := float32(0)
|
||||
for key, value := range alert.mapSums {
|
||||
sumTemp := float32(value) / float32(alert.count)
|
||||
if sumTemp > maxTemp {
|
||||
maxTemp = sumTemp
|
||||
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
||||
}
|
||||
}
|
||||
alert.val = float64(maxTemp)
|
||||
default:
|
||||
alert.val = alert.val / float64(alert.count)
|
||||
}
|
||||
minCount := float32(alert.min) / 1.2
|
||||
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||
// pass through alert if count is greater than or equal to minCount
|
||||
if float32(alert.count) >= minCount {
|
||||
if !alert.triggered && alert.val > alert.threshold {
|
||||
alert.triggered = true
|
||||
go am.sendSystemAlert(alert)
|
||||
} else if alert.triggered && alert.val <= alert.threshold {
|
||||
alert.triggered = false
|
||||
go am.sendSystemAlert(alert)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||
systemName := alert.systemRecord.GetString("name")
|
||||
|
||||
// change Disk to Disk usage
|
||||
if alert.name == "Disk" {
|
||||
alert.name += " usage"
|
||||
}
|
||||
|
||||
// make title alert name lowercase if not CPU
|
||||
titleAlertName := alert.name
|
||||
if titleAlertName != "CPU" {
|
||||
titleAlertName = strings.ToLower(titleAlertName)
|
||||
}
|
||||
|
||||
var subject string
|
||||
if alert.triggered {
|
||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||
} else {
|
||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||
}
|
||||
minutesLabel := "minute"
|
||||
if alert.min > 1 {
|
||||
minutesLabel += "s"
|
||||
}
|
||||
if alert.descriptor == "" {
|
||||
alert.descriptor = alert.name
|
||||
}
|
||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||
|
||||
alert.alertRecord.Set("triggered", alert.triggered)
|
||||
if err := am.app.Save(alert.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.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||
return
|
||||
}
|
||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
||||
am.SendAlert(AlertMessageData{
|
||||
UserID: user.Id,
|
||||
Title: subject,
|
||||
Message: body,
|
||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||
LinkText: "View " + systemName,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -160,13 +160,11 @@ export function SystemAlertGlobal({
|
||||
function AlertContent({ data }: { data: AlertData }) {
|
||||
const { key } = data
|
||||
|
||||
const hasSliders = !("single" in data.alert)
|
||||
const singleDescription = data.alert.singleDesc?.()
|
||||
|
||||
const [checked, setChecked] = useState(data.checked || false)
|
||||
const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0))
|
||||
const [value, setValue] = useState(data.val || (hasSliders ? 80 : 0))
|
||||
|
||||
const showSliders = checked && hasSliders
|
||||
const [min, setMin] = useState(data.min || 10)
|
||||
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
|
||||
|
||||
const newMin = useRef(min)
|
||||
const newValue = useRef(value)
|
||||
@@ -180,14 +178,14 @@ function AlertContent({ data }: { data: AlertData }) {
|
||||
<label
|
||||
htmlFor={`s${key}`}
|
||||
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
||||
"pb-0": showSliders,
|
||||
"pb-0": checked,
|
||||
})}
|
||||
>
|
||||
<div className="grid gap-1 select-none">
|
||||
<p className="font-semibold flex gap-3 items-center">
|
||||
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
||||
</p>
|
||||
{!showSliders && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
||||
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
||||
</div>
|
||||
<Switch
|
||||
id={`s${key}`}
|
||||
@@ -198,9 +196,10 @@ function AlertContent({ data }: { data: AlertData }) {
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{showSliders && (
|
||||
{checked && (
|
||||
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
|
||||
<Suspense fallback={<div className="h-10" />}>
|
||||
{!singleDescription && (
|
||||
<div>
|
||||
<p id={`v${key}`} className="text-sm block h-8">
|
||||
<Trans>
|
||||
@@ -222,8 +221,12 @@ function AlertContent({ data }: { data: AlertData }) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p id={`t${key}`} className="text-sm block h-8">
|
||||
)}
|
||||
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
||||
<p id={`t${key}`} className="text-sm block h-8 first-letter:uppercase">
|
||||
{singleDescription && (
|
||||
<>{singleDescription}{` `}</>
|
||||
)}
|
||||
<Trans>
|
||||
For <strong className="text-foreground">{min}</strong>{" "}
|
||||
<Plural value={min} one=" minute" other=" minutes" />
|
||||
|
||||
@@ -302,6 +302,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
const hasGpuData = lastGpuVals.length > 0
|
||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
||||
|
||||
let translatedStatus: string = system.status
|
||||
if (system.status === "up") {
|
||||
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
|
||||
} else if (system.status === "down") {
|
||||
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
||||
@@ -328,7 +335,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
||||
})}
|
||||
></span>
|
||||
</span>
|
||||
{system.status}
|
||||
{translatedStatus}
|
||||
</div>
|
||||
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
||||
if (hide || !value) {
|
||||
|
||||
@@ -302,7 +302,8 @@ export const alertInfo: Record<string, AlertInfo> = {
|
||||
unit: "",
|
||||
icon: ServerIcon,
|
||||
desc: () => t`Triggers when status switches between up and down`,
|
||||
single: true,
|
||||
/** "for x minutes" is appended to desc when only one value */
|
||||
singleDesc: () => t`System` + " " + t`Down`,
|
||||
},
|
||||
CPU: {
|
||||
name: () => t`CPU Usage`,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -103,27 +103,27 @@ msgstr "Are you sure you want to delete {name}?"
|
||||
msgid "Automatic copy requires a secure context."
|
||||
msgstr "Automatic copy requires a secure context."
|
||||
|
||||
#: src/components/routes/system.tsx:626
|
||||
#: src/components/routes/system.tsx:633
|
||||
msgid "Average"
|
||||
msgstr "Average"
|
||||
|
||||
#: src/components/routes/system.tsx:403
|
||||
#: src/components/routes/system.tsx:410
|
||||
msgid "Average CPU utilization of containers"
|
||||
msgstr "Average CPU utilization of containers"
|
||||
|
||||
#: src/components/alerts/alerts-system.tsx:206
|
||||
#: src/components/alerts/alerts-system.tsx:205
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "Average exceeds <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system.tsx:504
|
||||
#: src/components/routes/system.tsx:511
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "Average power consumption of GPUs"
|
||||
|
||||
#: src/components/routes/system.tsx:392
|
||||
#: src/components/routes/system.tsx:399
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "Average system-wide CPU utilization"
|
||||
|
||||
#: src/components/routes/system.tsx:522
|
||||
#: src/components/routes/system.tsx:529
|
||||
msgid "Average utilization of {0}"
|
||||
msgstr "Average utilization of {0}"
|
||||
|
||||
@@ -132,8 +132,8 @@ msgstr "Average utilization of {0}"
|
||||
msgid "Backups"
|
||||
msgstr "Backups"
|
||||
|
||||
#: src/components/routes/system.tsx:448
|
||||
#: src/lib/utils.ts:326
|
||||
#: src/components/routes/system.tsx:455
|
||||
#: src/lib/utils.ts:327
|
||||
msgid "Bandwidth"
|
||||
msgstr "Bandwidth"
|
||||
|
||||
@@ -229,8 +229,8 @@ msgid "CPU"
|
||||
msgstr "CPU"
|
||||
|
||||
#: src/components/charts/area-chart.tsx:56
|
||||
#: src/components/routes/system.tsx:391
|
||||
#: src/lib/utils.ts:308
|
||||
#: src/components/routes/system.tsx:398
|
||||
#: src/lib/utils.ts:309
|
||||
msgid "CPU Usage"
|
||||
msgstr "CPU Usage"
|
||||
|
||||
@@ -260,29 +260,29 @@ msgstr "Delete"
|
||||
msgid "Disk"
|
||||
msgstr "Disk"
|
||||
|
||||
#: src/components/routes/system.tsx:438
|
||||
#: src/components/routes/system.tsx:445
|
||||
msgid "Disk I/O"
|
||||
msgstr "Disk I/O"
|
||||
|
||||
#: src/components/charts/disk-chart.tsx:79
|
||||
#: src/components/routes/system.tsx:431
|
||||
#: src/lib/utils.ts:320
|
||||
#: src/components/routes/system.tsx:438
|
||||
#: src/lib/utils.ts:321
|
||||
msgid "Disk Usage"
|
||||
msgstr "Disk Usage"
|
||||
|
||||
#: src/components/routes/system.tsx:559
|
||||
#: src/components/routes/system.tsx:566
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "Disk usage of {extraFsName}"
|
||||
|
||||
#: src/components/routes/system.tsx:402
|
||||
#: src/components/routes/system.tsx:409
|
||||
msgid "Docker CPU Usage"
|
||||
msgstr "Docker CPU Usage"
|
||||
|
||||
#: src/components/routes/system.tsx:423
|
||||
#: src/components/routes/system.tsx:430
|
||||
msgid "Docker Memory Usage"
|
||||
msgstr "Docker Memory Usage"
|
||||
|
||||
#: src/components/routes/system.tsx:464
|
||||
#: src/components/routes/system.tsx:471
|
||||
msgid "Docker Network I/O"
|
||||
msgstr "Docker Network I/O"
|
||||
|
||||
@@ -290,6 +290,12 @@ msgstr "Docker Network I/O"
|
||||
msgid "Documentation"
|
||||
msgstr "Documentation"
|
||||
|
||||
#. Context: System is down
|
||||
#: src/components/routes/system.tsx:309
|
||||
#: src/lib/utils.ts:306
|
||||
msgid "Down"
|
||||
msgstr "Down"
|
||||
|
||||
#: src/components/add-system.tsx:124
|
||||
#: src/components/systems-table/systems-table.tsx:599
|
||||
msgid "Edit"
|
||||
@@ -351,12 +357,12 @@ msgstr "Failed to send test notification"
|
||||
msgid "Failed to update alert"
|
||||
msgstr "Failed to update alert"
|
||||
|
||||
#: src/components/routes/system.tsx:599
|
||||
#: src/components/routes/system.tsx:606
|
||||
#: src/components/systems-table/systems-table.tsx:326
|
||||
msgid "Filter..."
|
||||
msgstr "Filter..."
|
||||
|
||||
#: src/components/alerts/alerts-system.tsx:227
|
||||
#: src/components/alerts/alerts-system.tsx:230
|
||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||
msgstr "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||
|
||||
@@ -370,7 +376,7 @@ msgstr "Forgot password?"
|
||||
msgid "General"
|
||||
msgstr "General"
|
||||
|
||||
#: src/components/routes/system.tsx:503
|
||||
#: src/components/routes/system.tsx:510
|
||||
msgid "GPU Power Draw"
|
||||
msgstr "GPU Power Draw"
|
||||
|
||||
@@ -439,7 +445,7 @@ msgid "Manual setup instructions"
|
||||
msgstr "Manual setup instructions"
|
||||
|
||||
#. Chart select field. Please try to keep this short.
|
||||
#: src/components/routes/system.tsx:629
|
||||
#: src/components/routes/system.tsx:636
|
||||
msgid "Max 1 min"
|
||||
msgstr "Max 1 min"
|
||||
|
||||
@@ -447,12 +453,12 @@ msgstr "Max 1 min"
|
||||
msgid "Memory"
|
||||
msgstr "Memory"
|
||||
|
||||
#: src/components/routes/system.tsx:413
|
||||
#: src/lib/utils.ts:314
|
||||
#: src/components/routes/system.tsx:420
|
||||
#: src/lib/utils.ts:315
|
||||
msgid "Memory Usage"
|
||||
msgstr "Memory Usage"
|
||||
|
||||
#: src/components/routes/system.tsx:424
|
||||
#: src/components/routes/system.tsx:431
|
||||
msgid "Memory usage of docker containers"
|
||||
msgstr "Memory usage of docker containers"
|
||||
|
||||
@@ -464,11 +470,11 @@ msgstr "Name"
|
||||
msgid "Net"
|
||||
msgstr "Net"
|
||||
|
||||
#: src/components/routes/system.tsx:465
|
||||
#: src/components/routes/system.tsx:472
|
||||
msgid "Network traffic of docker containers"
|
||||
msgstr "Network traffic of docker containers"
|
||||
|
||||
#: src/components/routes/system.tsx:450
|
||||
#: src/components/routes/system.tsx:457
|
||||
msgid "Network traffic of public interfaces"
|
||||
msgstr "Network traffic of public interfaces"
|
||||
|
||||
@@ -573,8 +579,8 @@ msgstr "Please sign in to your account"
|
||||
msgid "Port"
|
||||
msgstr "Port"
|
||||
|
||||
#: src/components/routes/system.tsx:414
|
||||
#: src/components/routes/system.tsx:530
|
||||
#: src/components/routes/system.tsx:421
|
||||
#: src/components/routes/system.tsx:537
|
||||
msgid "Precise utilization at the recorded time"
|
||||
msgstr "Precise utilization at the recorded time"
|
||||
|
||||
@@ -668,11 +674,11 @@ msgstr "Sort By"
|
||||
msgid "Status"
|
||||
msgstr "Status"
|
||||
|
||||
#: src/components/routes/system.tsx:480
|
||||
#: src/components/routes/system.tsx:487
|
||||
msgid "Swap space used by the system"
|
||||
msgstr "Swap space used by the system"
|
||||
|
||||
#: src/components/routes/system.tsx:479
|
||||
#: src/components/routes/system.tsx:486
|
||||
msgid "Swap Usage"
|
||||
msgstr "Swap Usage"
|
||||
|
||||
@@ -682,6 +688,7 @@ msgstr "Swap Usage"
|
||||
#: src/components/systems-table/systems-table.tsx:133
|
||||
#: src/components/systems-table/systems-table.tsx:144
|
||||
#: src/components/systems-table/systems-table.tsx:518
|
||||
#: src/lib/utils.ts:306
|
||||
msgid "System"
|
||||
msgstr "System"
|
||||
|
||||
@@ -702,12 +709,12 @@ msgstr "Table"
|
||||
msgid "Temp"
|
||||
msgstr "Temp"
|
||||
|
||||
#: src/components/routes/system.tsx:491
|
||||
#: src/lib/utils.ts:333
|
||||
#: src/components/routes/system.tsx:498
|
||||
#: src/lib/utils.ts:334
|
||||
msgid "Temperature"
|
||||
msgstr "Temperature"
|
||||
|
||||
#: src/components/routes/system.tsx:492
|
||||
#: src/components/routes/system.tsx:499
|
||||
msgid "Temperatures of system sensors"
|
||||
msgstr "Temperatures of system sensors"
|
||||
|
||||
@@ -735,11 +742,11 @@ msgstr "Then log into the backend and reset your user account password in the us
|
||||
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||
msgstr "This action cannot be undone. This will permanently delete all current records for {name} from the database."
|
||||
|
||||
#: src/components/routes/system.tsx:571
|
||||
#: src/components/routes/system.tsx:578
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "Throughput of {extraFsName}"
|
||||
|
||||
#: src/components/routes/system.tsx:439
|
||||
#: src/components/routes/system.tsx:446
|
||||
msgid "Throughput of root filesystem"
|
||||
msgstr "Throughput of root filesystem"
|
||||
|
||||
@@ -747,8 +754,8 @@ msgstr "Throughput of root filesystem"
|
||||
msgid "To email(s)"
|
||||
msgstr "To email(s)"
|
||||
|
||||
#: src/components/routes/system.tsx:366
|
||||
#: src/components/routes/system.tsx:379
|
||||
#: src/components/routes/system.tsx:373
|
||||
#: src/components/routes/system.tsx:386
|
||||
msgid "Toggle grid"
|
||||
msgstr "Toggle grid"
|
||||
|
||||
@@ -756,19 +763,19 @@ msgstr "Toggle grid"
|
||||
msgid "Toggle theme"
|
||||
msgstr "Toggle theme"
|
||||
|
||||
#: src/lib/utils.ts:336
|
||||
#: src/lib/utils.ts:337
|
||||
msgid "Triggers when any sensor exceeds a threshold"
|
||||
msgstr "Triggers when any sensor exceeds a threshold"
|
||||
|
||||
#: src/lib/utils.ts:329
|
||||
#: src/lib/utils.ts:330
|
||||
msgid "Triggers when combined up/down exceeds a threshold"
|
||||
msgstr "Triggers when combined up/down exceeds a threshold"
|
||||
|
||||
#: src/lib/utils.ts:311
|
||||
#: src/lib/utils.ts:312
|
||||
msgid "Triggers when CPU usage exceeds a threshold"
|
||||
msgstr "Triggers when CPU usage exceeds a threshold"
|
||||
|
||||
#: src/lib/utils.ts:317
|
||||
#: src/lib/utils.ts:318
|
||||
msgid "Triggers when memory usage exceeds a threshold"
|
||||
msgstr "Triggers when memory usage exceeds a threshold"
|
||||
|
||||
@@ -776,10 +783,15 @@ msgstr "Triggers when memory usage exceeds a threshold"
|
||||
msgid "Triggers when status switches between up and down"
|
||||
msgstr "Triggers when status switches between up and down"
|
||||
|
||||
#: src/lib/utils.ts:323
|
||||
#: src/lib/utils.ts:324
|
||||
msgid "Triggers when usage of any disk exceeds a threshold"
|
||||
msgstr "Triggers when usage of any disk exceeds a threshold"
|
||||
|
||||
#. Context: System is up
|
||||
#: src/components/routes/system.tsx:307
|
||||
msgid "Up"
|
||||
msgstr "Up"
|
||||
|
||||
#: src/components/systems-table/systems-table.tsx:322
|
||||
msgid "Updated in real time. Click on a system to view information."
|
||||
msgstr "Updated in real time. Click on a system to view information."
|
||||
@@ -789,12 +801,12 @@ msgid "Uptime"
|
||||
msgstr "Uptime"
|
||||
|
||||
#: src/components/charts/area-chart.tsx:73
|
||||
#: src/components/routes/system.tsx:521
|
||||
#: src/components/routes/system.tsx:558
|
||||
#: src/components/routes/system.tsx:528
|
||||
#: src/components/routes/system.tsx:565
|
||||
msgid "Usage"
|
||||
msgstr "Usage"
|
||||
|
||||
#: src/components/routes/system.tsx:431
|
||||
#: src/components/routes/system.tsx:438
|
||||
msgid "Usage of root partition"
|
||||
msgstr "Usage of root partition"
|
||||
|
||||
@@ -817,7 +829,7 @@ msgstr "View"
|
||||
msgid "Visible Fields"
|
||||
msgstr "Visible Fields"
|
||||
|
||||
#: src/components/routes/system.tsx:663
|
||||
#: src/components/routes/system.tsx:670
|
||||
msgid "Waiting for enough records to display"
|
||||
msgstr "Waiting for enough records to display"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
3
beszel/site/src/types.d.ts
vendored
3
beszel/site/src/types.d.ts
vendored
@@ -200,6 +200,7 @@ interface AlertInfo {
|
||||
unit: string
|
||||
icon: any
|
||||
desc: () => string
|
||||
single?: boolean
|
||||
max?: number
|
||||
/** Single value description (when there's only one value, like status) */
|
||||
singleDesc?: () => string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user