Compare commits

...

108 Commits

Author SHA1 Message Date
hank
7fee3da2e8 New translations en.po (Ukrainian) 2025-04-27 07:20:55 -04:00
hank
443e203d9c New translations en.po (German) 2025-04-27 06:25:22 -04:00
hank
24875b5733 New translations en.po (Chinese Traditional, Hong Kong) 2025-04-26 17:53:34 -04:00
hank
66eaf45c79 New translations en.po (Croatian) 2025-04-26 17:53:33 -04:00
hank
2b9fb8b1ec New translations en.po (Persian) 2025-04-26 17:53:33 -04:00
hank
06e0ae4733 New translations en.po (Icelandic) 2025-04-26 17:53:32 -04:00
hank
4cc0bfeb0f New translations en.po (Vietnamese) 2025-04-26 17:53:31 -04:00
hank
320d0e5d97 New translations en.po (Chinese Traditional) 2025-04-26 17:53:30 -04:00
hank
67953bb6af New translations en.po (Chinese Simplified) 2025-04-26 17:53:29 -04:00
hank
123c57ded9 New translations en.po (Ukrainian) 2025-04-26 17:53:28 -04:00
hank
a7991ca184 New translations en.po (Turkish) 2025-04-26 17:53:27 -04:00
hank
1b01410533 New translations en.po (Swedish) 2025-04-26 17:53:26 -04:00
hank
1687919f31 New translations en.po (Slovenian) 2025-04-26 17:53:25 -04:00
hank
6046dbb727 New translations en.po (Russian) 2025-04-26 17:53:24 -04:00
hank
2505b16faa New translations en.po (Portuguese) 2025-04-26 17:53:23 -04:00
hank
c4352e65fb New translations en.po (Polish) 2025-04-26 17:53:22 -04:00
hank
7b2e9ccdcb New translations en.po (Norwegian) 2025-04-26 17:53:21 -04:00
hank
75a87cddbf New translations en.po (Korean) 2025-04-26 17:53:20 -04:00
hank
bda7b9b48d New translations en.po (Japanese) 2025-04-26 17:53:19 -04:00
hank
65d6ec1918 New translations en.po (Italian) 2025-04-26 17:53:18 -04:00
hank
0e16fca07f New translations en.po (Hungarian) 2025-04-26 17:53:17 -04:00
hank
b7e574f379 New translations en.po (German) 2025-04-26 17:53:16 -04:00
hank
113f1833e1 New translations en.po (Danish) 2025-04-26 17:53:15 -04:00
hank
a0d3e60d29 New translations en.po (Czech) 2025-04-26 17:53:14 -04:00
hank
df7f12bfec New translations en.po (Bulgarian) 2025-04-26 17:53:13 -04:00
hank
02e88d99e5 New translations en.po (Spanish) 2025-04-26 17:53:12 -04:00
hank
a11ceb36d1 New translations en.po (French) 2025-04-26 17:53:11 -04:00
hank
d3a373338d New translations en.po (Arabic) 2025-04-26 17:53:10 -04:00
hank
e1ef05da8c New translations en.po (Dutch) 2025-04-26 17:53:09 -04:00
hank
c01bfd9332 New translations en.po (Arabic) 2025-04-23 13:19:56 -04:00
hank
209e82aed2 New translations en.po (Dutch) 2025-04-14 09:04:03 -04:00
hank
c66e740d52 New translations en.po (Arabic) 2025-04-05 21:52:03 -04:00
hank
551ae49c2e New translations en.po (French) 2025-03-28 17:04:47 -04:00
hank
906daa6f2a New translations en.po (French) 2025-03-26 06:03:01 -04:00
hank
49e55943c1 New translations en.po (Russian) 2025-03-25 04:32:17 -04:00
hank
9e75cbc1ef New translations en.po (Italian) 2025-03-22 11:29:03 -04:00
hank
79190a2c51 New translations en.po (Norwegian) 2025-03-17 09:40:03 -04:00
hank
32960c7c35 New translations en.po (Chinese Simplified) 2025-03-15 04:13:35 -04:00
hank
cdff974a8a New translations en.po (Ukrainian) 2025-03-15 00:14:37 -04:00
hank
20a6ef129c New translations en.po (Czech) 2025-03-13 20:50:03 -04:00
hank
d1e5310f83 New translations en.po (Japanese) 2025-03-13 06:13:10 -04:00
hank
152173eab5 New translations en.po (Korean) 2025-03-07 05:06:11 -05:00
hank
c2aec69638 New translations en.po (Ukrainian) 2025-03-06 07:37:37 -05:00
hank
16c97fe77b New translations en.po (Chinese Traditional, Hong Kong) 2025-03-06 02:27:48 -05:00
hank
bed5bc2791 New translations en.po (Croatian) 2025-03-06 02:27:47 -05:00
hank
424c7aceff New translations en.po (Persian) 2025-03-06 02:27:46 -05:00
hank
8eb101e714 New translations en.po (Vietnamese) 2025-03-06 02:27:45 -05:00
hank
f2e69e2a80 New translations en.po (Chinese Traditional) 2025-03-06 02:27:44 -05:00
hank
41b4dbce98 New translations en.po (Chinese Simplified) 2025-03-06 02:27:43 -05:00
hank
c3432fc7b5 New translations en.po (Ukrainian) 2025-03-06 02:27:42 -05:00
hank
b167244e28 New translations en.po (Turkish) 2025-03-06 02:27:40 -05:00
hank
bcca6a5a9d New translations en.po (Swedish) 2025-03-06 02:27:39 -05:00
hank
6b661b4878 New translations en.po (Slovenian) 2025-03-06 02:27:37 -05:00
hank
bc537edb73 New translations en.po (Russian) 2025-03-06 02:27:36 -05:00
hank
561a3e8aaf New translations en.po (Polish) 2025-03-06 02:27:34 -05:00
hank
f173ea37da New translations en.po (Norwegian) 2025-03-06 02:27:33 -05:00
hank
0d9f2ba06a New translations en.po (Dutch) 2025-03-06 02:27:32 -05:00
hank
4e772e6008 New translations en.po (Korean) 2025-03-06 02:27:31 -05:00
hank
b959d97502 New translations en.po (Italian) 2025-03-06 02:27:30 -05:00
hank
735cbe2cf0 New translations en.po (German) 2025-03-06 02:27:29 -05:00
hank
0f5a470495 New translations en.po (Danish) 2025-03-06 02:27:28 -05:00
hank
a42e99370e New translations en.po (Czech) 2025-03-06 02:27:27 -05:00
hank
abfae78af1 New translations en.po (Bulgarian) 2025-03-06 02:27:26 -05:00
hank
e36c40c4a9 New translations en.po (Arabic) 2025-03-06 02:27:25 -05:00
hank
bf7b2ae598 New translations en.po (Spanish) 2025-03-06 02:27:24 -05:00
hank
f71ee4b058 New translations en.po (French) 2025-03-06 02:27:23 -05:00
hank
f2466eb37d New translations en.po (Hungarian) 2025-03-06 02:27:22 -05:00
hank
7244c7130b New translations en.po (Japanese) 2025-03-06 02:27:21 -05:00
hank
f2e84a9d3e New translations en.po (Portuguese) 2025-03-06 02:27:20 -05:00
hank
04a1ee5e4e New translations en.po (Icelandic) 2025-03-06 02:27:19 -05:00
hank
9b83088897 New translations en.po (Norwegian) 2025-03-05 15:49:42 -05:00
hank
13b30aa255 New translations en.po (Russian) 2025-03-01 13:24:35 -05:00
hank
2bd25e9e8d New translations en.po (Ukrainian) 2025-03-01 08:20:09 -05:00
hank
4fe192bf28 New translations en.po (Ukrainian) 2025-03-01 07:11:52 -05:00
hank
2056ae285f New translations en.po (Spanish) 2025-02-28 12:20:35 -05:00
hank
a02e7a0a69 New translations en.po (Spanish) 2025-02-28 11:07:32 -05:00
hank
b1a9e90034 New translations en.po (Chinese Traditional, Hong Kong) 2025-02-27 17:31:32 -05:00
hank
20916fab3e New translations en.po (Croatian) 2025-02-27 17:31:31 -05:00
hank
0c777cca72 New translations en.po (Persian) 2025-02-27 17:31:31 -05:00
hank
44e30ad429 New translations en.po (Vietnamese) 2025-02-27 17:31:30 -05:00
hank
9e30786dda New translations en.po (Chinese Traditional) 2025-02-27 17:31:29 -05:00
hank
1f677773e7 New translations en.po (Chinese Simplified) 2025-02-27 17:31:28 -05:00
hank
882289da91 New translations en.po (Ukrainian) 2025-02-27 17:31:26 -05:00
hank
fbbc4eff27 New translations en.po (Turkish) 2025-02-27 17:31:25 -05:00
hank
b78231f677 New translations en.po (Swedish) 2025-02-27 17:31:24 -05:00
hank
332e2d14a9 New translations en.po (Slovenian) 2025-02-27 17:31:23 -05:00
hank
e088b88c84 New translations en.po (Russian) 2025-02-27 17:31:22 -05:00
hank
09840f95d9 New translations en.po (Polish) 2025-02-27 17:31:21 -05:00
hank
de03bb658a New translations en.po (Norwegian) 2025-02-27 17:31:19 -05:00
hank
bcadfeb729 New translations en.po (Dutch) 2025-02-27 17:31:18 -05:00
hank
ec582a9171 New translations en.po (Korean) 2025-02-27 17:31:17 -05:00
hank
0c3eaefc90 New translations en.po (Italian) 2025-02-27 17:31:16 -05:00
hank
d6b5866f90 New translations en.po (German) 2025-02-27 17:31:15 -05:00
hank
2ba629e4b4 New translations en.po (Danish) 2025-02-27 17:31:14 -05:00
hank
6e9341b7ff New translations en.po (Czech) 2025-02-27 17:31:13 -05:00
hank
89499a341b New translations en.po (Bulgarian) 2025-02-27 17:31:12 -05:00
hank
9c88845798 New translations en.po (Arabic) 2025-02-27 17:31:10 -05:00
hank
c9f65f63e6 New translations en.po (Spanish) 2025-02-27 17:31:09 -05:00
hank
996620c8e0 New translations en.po (French) 2025-02-27 17:31:08 -05:00
hank
626c1358d9 New translations en.po (Hungarian) 2025-02-27 17:31:07 -05:00
hank
19dd39b7db New translations en.po (Japanese) 2025-02-27 17:31:06 -05:00
hank
d482b50e31 New translations en.po (Portuguese) 2025-02-27 17:31:05 -05:00
hank
f61ec49c76 New translations en.po (Icelandic) 2025-02-27 17:31:03 -05:00
henrygd
2b43ba3cbe i18n: update language files 2025-02-27 17:20:12 -05:00
ArsFy
b2b1a0b6ea i18n: new Chinese translations 2025-02-27 17:19:04 -05:00
stanol
b11d0aae61 i18n: new Ukrainian translations 2025-02-27 17:18:35 -05:00
henrygd
2b73d8845a feat: allow x min downtime before alerting (#595, #625)
- splits alerts package into three files. status alerts were not
modified aside from updating to slices.Delete method
2025-02-27 17:12:25 -05:00
henrygd
41e3e3d760 chore: update .gitignore 2025-02-27 00:37:29 -05:00
36 changed files with 8384 additions and 7299 deletions

3
.gitignore vendored
View File

@@ -14,4 +14,5 @@ node_modules
beszel/build
*timestamp*
.swc
beszel/site/src/locales/**/*.ts
beszel/site/src/locales/**/*.ts
*.bak

View File

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

View 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,
})
}

View 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,
})
}
}

View File

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

View File

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

View File

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

View File

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

View File

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