mirror of
https://github.com/fankes/beszel.git
synced 2025-10-19 09:49:28 +08:00
This commit is contained in:
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/nicholas-fedor/shoutrrr"
|
"github.com/nicholas-fedor/shoutrrr"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
)
|
)
|
||||||
@@ -206,16 +205,14 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
var data struct {
|
||||||
if info.Auth == nil {
|
URL string `json:"url"`
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
}
|
||||||
url := e.Request.URL.Query().Get("url")
|
err := e.BindBody(&data)
|
||||||
// log.Println("url", url)
|
if err != nil || data.URL == "" {
|
||||||
if url == "" {
|
return e.BadRequestError("URL is required", err)
|
||||||
return e.JSON(200, map[string]string{"err": "URL is required"})
|
|
||||||
}
|
}
|
||||||
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
}
|
}
|
||||||
|
119
beszel/internal/alerts/alerts_api.go
Normal file
119
beszel/internal/alerts/alerts_api.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpsertUserAlerts handles API request to create or update alerts for a user
|
||||||
|
// across multiple systems (POST /api/beszel/user-alerts)
|
||||||
|
func UpsertUserAlerts(e *core.RequestEvent) error {
|
||||||
|
userID := e.Auth.Id
|
||||||
|
|
||||||
|
reqData := struct {
|
||||||
|
Min uint8 `json:"min"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Systems []string `json:"systems"`
|
||||||
|
Overwrite bool `json:"overwrite"`
|
||||||
|
}{}
|
||||||
|
err := e.BindBody(&reqData)
|
||||||
|
if err != nil || userID == "" || reqData.Name == "" || len(reqData.Systems) == 0 {
|
||||||
|
return e.BadRequestError("Bad data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertsCollection, err := e.App.FindCachedCollectionByNameOrId("alerts")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
for _, systemId := range reqData.Systems {
|
||||||
|
// find existing matching alert
|
||||||
|
alertRecord, err := txApp.FindFirstRecordByFilter(alertsCollection,
|
||||||
|
"system={:system} && name={:name} && user={:user}",
|
||||||
|
dbx.Params{"system": systemId, "name": reqData.Name, "user": userID})
|
||||||
|
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// skip if alert already exists and overwrite is not set
|
||||||
|
if !reqData.Overwrite && alertRecord != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// create new alert if it doesn't exist
|
||||||
|
if alertRecord == nil {
|
||||||
|
alertRecord = core.NewRecord(alertsCollection)
|
||||||
|
alertRecord.Set("user", userID)
|
||||||
|
alertRecord.Set("system", systemId)
|
||||||
|
alertRecord.Set("name", reqData.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
alertRecord.Set("value", reqData.Value)
|
||||||
|
alertRecord.Set("min", reqData.Min)
|
||||||
|
|
||||||
|
if err := txApp.SaveNoValidate(alertRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteUserAlerts handles API request to delete alerts for a user across multiple systems
|
||||||
|
// (DELETE /api/beszel/user-alerts)
|
||||||
|
func DeleteUserAlerts(e *core.RequestEvent) error {
|
||||||
|
userID := e.Auth.Id
|
||||||
|
|
||||||
|
reqData := struct {
|
||||||
|
AlertName string `json:"name"`
|
||||||
|
Systems []string `json:"systems"`
|
||||||
|
}{}
|
||||||
|
err := e.BindBody(&reqData)
|
||||||
|
if err != nil || userID == "" || reqData.AlertName == "" || len(reqData.Systems) == 0 {
|
||||||
|
return e.BadRequestError("Bad data", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var numDeleted uint16
|
||||||
|
|
||||||
|
err = e.App.RunInTransaction(func(txApp core.App) error {
|
||||||
|
for _, systemId := range reqData.Systems {
|
||||||
|
// Find existing alert to delete
|
||||||
|
alertRecord, err := txApp.FindFirstRecordByFilter("alerts",
|
||||||
|
"system={:system} && name={:name} && user={:user}",
|
||||||
|
dbx.Params{"system": systemId, "name": reqData.AlertName, "user": userID})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// alert doesn't exist, continue to next system
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := txApp.Delete(alertRecord); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
numDeleted++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||||
|
}
|
368
beszel/internal/alerts/alerts_test.go
Normal file
368
beszel/internal/alerts/alerts_test.go
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
beszelTests "beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
|
func jsonReader(v any) io.Reader {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUserAlertsApi(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||||
|
user1Token, _ := user1.NewAuthToken()
|
||||||
|
|
||||||
|
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||||
|
user2Token, _ := user2.NewAuthToken()
|
||||||
|
|
||||||
|
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system1",
|
||||||
|
"users": []string{user1.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "system2",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
"host": "127.0.0.2",
|
||||||
|
})
|
||||||
|
|
||||||
|
userRecords, _ := hub.CountRecords("users")
|
||||||
|
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||||
|
|
||||||
|
systemRecords, _ := hub.CountRecords("systems")
|
||||||
|
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
Name: "GET not implemented - returns index",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no auth",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST no body",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST bad data",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"invalidField": "this should cause validation error",
|
||||||
|
"threshold": "not a number",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST malformed JSON",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{"Bad data"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data multiple systems",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 69,
|
||||||
|
"min": 9,
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
// check total alerts
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
// check alert has correct values
|
||||||
|
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||||
|
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST valid alert data single system",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
}),
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: false, should not overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
"overwrite": false,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Overwrite: true, should overwrite existing alert",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 45,
|
||||||
|
"min": 5,
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
"overwrite": true,
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user2.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE no auth",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system1.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system1.Id,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE alert multiple systems",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user1Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"systems": []string{system1.Id, system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||||
|
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "Memory",
|
||||||
|
"system": systemId,
|
||||||
|
"user": user1.Id,
|
||||||
|
"value": 90,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
assert.NoError(t, err, "should create alert")
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.Zero(t, alerts, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "User 2 should not be able to delete alert of user 1",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system2.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
beszelTests.ClearCollection(t, app, "alerts")
|
||||||
|
for _, user := range []string{user1.Id, user2.Id} {
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system2.Id,
|
||||||
|
"user": user,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
alerts, _ := app.CountRecords("alerts")
|
||||||
|
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||||
|
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||||
|
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||||
|
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
@@ -251,10 +251,10 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||||
// get or create universal tokens
|
// get or create universal tokens
|
||||||
apiAuth.GET("/universal-token", h.getUniversalToken)
|
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||||
// create first user endpoint only needed if no users exist
|
// update / delete user alerts
|
||||||
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -6,9 +6,12 @@ package tests
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/hub"
|
"beszel/internal/hub"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tests"
|
"github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
_ "github.com/pocketbase/pocketbase/migrations"
|
_ "github.com/pocketbase/pocketbase/migrations"
|
||||||
)
|
)
|
||||||
@@ -86,3 +89,10 @@ func CreateRecord(app core.App, collectionName string, fields map[string]any) (*
|
|||||||
|
|
||||||
return record, app.Save(record)
|
return record, app.Save(record)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ClearCollection(t testing.TB, app core.App, collectionName string) error {
|
||||||
|
_, err := app.DB().NewQuery(fmt.Sprintf("DELETE from %s", collectionName)).Execute()
|
||||||
|
recordCount, err := app.CountRecords(collectionName)
|
||||||
|
assert.EqualValues(t, recordCount, 0, "should have 0 records after clearing")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
@@ -1,31 +1,19 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro"
|
|
||||||
import { memo, useMemo, useState } from "react"
|
import { memo, useMemo, useState } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
import {
|
import { Dialog, DialogTrigger, DialogContent } from "@/components/ui/dialog"
|
||||||
Dialog,
|
import { BellIcon } from "lucide-react"
|
||||||
DialogTrigger,
|
import { cn } from "@/lib/utils"
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog"
|
|
||||||
import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
import { $router, Link } from "../router"
|
import { AlertDialogContent } from "./alerts-dialog"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
||||||
import { Checkbox } from "../ui/checkbox"
|
|
||||||
import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
|
|
||||||
import { getPagePath } from "@nanostores/router"
|
|
||||||
|
|
||||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const [opened, setOpened] = useState(false)
|
const [opened, setOpened] = useState(false)
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
|
||||||
const hasAlert = alerts.some((alert) => alert.system === system.id)
|
const hasSystemAlert = alerts[system.id]?.size > 0
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
@@ -34,7 +22,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
||||||
"fill-primary": hasAlert,
|
"fill-primary": hasSystemAlert,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -44,7 +32,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
),
|
),
|
||||||
[opened, hasAlert]
|
[opened, hasSystemAlert]
|
||||||
)
|
)
|
||||||
|
|
||||||
// return useMemo(
|
// return useMemo(
|
||||||
@@ -67,87 +55,3 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
// [opened, hasAlert]
|
// [opened, hasAlert]
|
||||||
// )
|
// )
|
||||||
})
|
})
|
||||||
|
|
||||||
function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
|
||||||
|
|
||||||
/* key to prevent re-rendering */
|
|
||||||
const alertsSignature: string[] = []
|
|
||||||
|
|
||||||
const systemAlerts = alerts.filter((alert) => {
|
|
||||||
if (alert.system === system.id) {
|
|
||||||
alertsSignature.push(alert.name, alert.min, alert.value)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}) as AlertRecord[]
|
|
||||||
|
|
||||||
return useMemo(() => {
|
|
||||||
const data = Object.keys(alertInfo).map((name) => {
|
|
||||||
const alert = alertInfo[name as keyof typeof alertInfo]
|
|
||||||
return {
|
|
||||||
name: name as keyof typeof alertInfo,
|
|
||||||
alert,
|
|
||||||
system,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl">
|
|
||||||
<Trans>Alerts</Trans>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
<Trans>
|
|
||||||
See{" "}
|
|
||||||
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
|
|
||||||
notification settings
|
|
||||||
</Link>{" "}
|
|
||||||
to configure how you receive alerts.
|
|
||||||
</Trans>
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<Tabs defaultValue="system">
|
|
||||||
<TabsList className="mb-1 -mt-0.5">
|
|
||||||
<TabsTrigger value="system">
|
|
||||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
|
||||||
{system.name}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="global">
|
|
||||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
|
||||||
<Trans>All Systems</Trans>
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="system">
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{data.map((d) => (
|
|
||||||
<SystemAlert key={d.name} system={system} data={d} systemAlerts={systemAlerts} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="global">
|
|
||||||
<label
|
|
||||||
htmlFor="ovw"
|
|
||||||
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id="ovw"
|
|
||||||
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
|
||||||
checked={overwriteExisting}
|
|
||||||
onCheckedChange={setOverwriteExisting}
|
|
||||||
/>
|
|
||||||
<Trans>Overwrite existing alerts</Trans>
|
|
||||||
</label>
|
|
||||||
<div className="grid gap-3">
|
|
||||||
{data.map((d) => (
|
|
||||||
<SystemAlertGlobal key={d.name} data={d} overwrite={overwriteExisting} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}, [alertsSignature.join(""), overwriteExisting])
|
|
||||||
}
|
|
||||||
|
@@ -1,225 +1,207 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans, Plural } from "@lingui/react/macro"
|
import { Trans, Plural } from "@lingui/react/macro"
|
||||||
import { $alerts, $systems, pb } from "@/lib/stores"
|
import { $alerts, $systems, pb } from "@/lib/stores"
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
import { alertInfo, cn, debounce } from "@/lib/utils"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
||||||
import { lazy, Suspense, useMemo, useState } from "react"
|
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "@/components/ui/use-toast"
|
||||||
import { BatchService } from "pocketbase"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getSemaphore } from "@henrygd/semaphore"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
interface AlertData {
|
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
checked?: boolean
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
val?: number
|
import { ServerIcon, GlobeIcon } from "lucide-react"
|
||||||
min?: number
|
import { $router, Link } from "@/components/router"
|
||||||
updateAlert?: (checked: boolean, value: number, min: number) => void
|
import { DialogHeader } from "@/components/ui/dialog"
|
||||||
name: keyof typeof alertInfo
|
|
||||||
alert: AlertInfo
|
|
||||||
system: SystemRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||||
|
|
||||||
const failedUpdateToast = () =>
|
const endpoint = "/api/beszel/user-alerts"
|
||||||
|
|
||||||
|
const alertDebounce = 100
|
||||||
|
|
||||||
|
const alertKeys = Object.keys(alertInfo) as (keyof typeof alertInfo)[]
|
||||||
|
|
||||||
|
const failedUpdateToast = (error: unknown) => {
|
||||||
|
console.error(error)
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to update alert`,
|
title: t`Failed to update alert`,
|
||||||
description: t`Please check logs for more details.`,
|
description: t`Please check logs for more details.`,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export function SystemAlert({
|
/** Create or update alerts for a given name and systems */
|
||||||
system,
|
const upsertAlerts = debounce(
|
||||||
systemAlerts,
|
async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
|
||||||
data,
|
|
||||||
}: {
|
|
||||||
system: SystemRecord
|
|
||||||
systemAlerts: AlertRecord[]
|
|
||||||
data: AlertData
|
|
||||||
}) {
|
|
||||||
const alert = systemAlerts.find((alert) => alert.name === data.name)
|
|
||||||
|
|
||||||
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
|
||||||
try {
|
try {
|
||||||
if (alert && !checked) {
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
await pb.collection("alerts").delete(alert.id)
|
method: "POST",
|
||||||
} else if (alert && checked) {
|
// overwrite is always true because we've done filtering client side
|
||||||
await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
|
body: { name, value, min, systems, overwrite: true },
|
||||||
} else if (checked) {
|
|
||||||
pb.collection("alerts").create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.record!.id,
|
|
||||||
name: data.name,
|
|
||||||
value: value,
|
|
||||||
min: min,
|
|
||||||
})
|
})
|
||||||
|
} catch (error) {
|
||||||
|
failedUpdateToast(error)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
},
|
||||||
failedUpdateToast()
|
alertDebounce
|
||||||
}
|
)
|
||||||
}
|
|
||||||
|
|
||||||
if (alert) {
|
/** Delete alerts for a given name and systems */
|
||||||
data.checked = true
|
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
|
||||||
data.val = alert.value
|
|
||||||
data.min = alert.min || 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AlertContent data={data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SystemAlertGlobal = ({ data, overwrite }: { data: AlertData; overwrite: boolean | "indeterminate" }) => {
|
|
||||||
data.checked = false
|
|
||||||
data.val = data.min = 0
|
|
||||||
|
|
||||||
// set of system ids that have an alert for this name when the component is mounted
|
|
||||||
const existingAlertsSystems = useMemo(() => {
|
|
||||||
const map = new Set<string>()
|
|
||||||
const alerts = $alerts.get()
|
|
||||||
for (const alert of alerts) {
|
|
||||||
if (alert.name === data.name) {
|
|
||||||
map.add(alert.system)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return map
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
data.updateAlert = async (checked: boolean, value: number, min: number) => {
|
|
||||||
const sem = getSemaphore("alerts")
|
|
||||||
await sem.acquire()
|
|
||||||
try {
|
try {
|
||||||
// if another update is waiting behind, don't start this one
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
if (sem.size() > 1) {
|
method: "DELETE",
|
||||||
return
|
body: { name, systems },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
failedUpdateToast(error)
|
||||||
|
}
|
||||||
|
}, alertDebounce)
|
||||||
|
|
||||||
|
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||||
|
const [currentTab, setCurrentTab] = useState("system")
|
||||||
|
|
||||||
|
const systemAlerts = alerts[system.id] ?? new Map()
|
||||||
|
|
||||||
|
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
|
||||||
|
// current alerts, it will only be updated when first checked, then won't be updated because
|
||||||
|
// after that it exists.
|
||||||
|
const alertsWhenGlobalSelected = useMemo(() => {
|
||||||
|
return currentTab === "global" ? structuredClone(alerts) : alerts
|
||||||
|
}, [currentTab])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl">
|
||||||
|
<Trans>Alerts</Trans>
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
<Trans>
|
||||||
|
See{" "}
|
||||||
|
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
|
||||||
|
notification settings
|
||||||
|
</Link>{" "}
|
||||||
|
to configure how you receive alerts.
|
||||||
|
</Trans>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
|
||||||
|
<TabsList className="mb-1 -mt-0.5">
|
||||||
|
<TabsTrigger value="system">
|
||||||
|
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||||
|
{system.name}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="global">
|
||||||
|
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||||
|
<Trans>All Systems</Trans>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="system">
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{alertKeys.map((name) => (
|
||||||
|
<AlertContent
|
||||||
|
key={name}
|
||||||
|
alertKey={name}
|
||||||
|
data={alertInfo[name as keyof typeof alertInfo]}
|
||||||
|
alert={systemAlerts.get(name)}
|
||||||
|
system={system}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="global">
|
||||||
|
<label
|
||||||
|
htmlFor="ovw"
|
||||||
|
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id="ovw"
|
||||||
|
className="text-destructive border-destructive data-[state=checked]:bg-destructive"
|
||||||
|
checked={overwriteExisting}
|
||||||
|
onCheckedChange={setOverwriteExisting}
|
||||||
|
/>
|
||||||
|
<Trans>Overwrite existing alerts</Trans>
|
||||||
|
</label>
|
||||||
|
<div className="grid gap-3">
|
||||||
|
{alertKeys.map((name) => (
|
||||||
|
<AlertContent
|
||||||
|
key={name}
|
||||||
|
alertKey={name}
|
||||||
|
system={system}
|
||||||
|
alert={systemAlerts.get(name)}
|
||||||
|
data={alertInfo[name as keyof typeof alertInfo]}
|
||||||
|
global={true}
|
||||||
|
overwriteExisting={!!overwriteExisting}
|
||||||
|
initialAlertsState={alertsWhenGlobalSelected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export function AlertContent({
|
||||||
|
alertKey,
|
||||||
|
data: alertData,
|
||||||
|
system,
|
||||||
|
alert,
|
||||||
|
global = false,
|
||||||
|
overwriteExisting = false,
|
||||||
|
initialAlertsState = {},
|
||||||
|
}: {
|
||||||
|
alertKey: string
|
||||||
|
data: AlertInfo
|
||||||
|
system: SystemRecord
|
||||||
|
alert?: AlertRecord
|
||||||
|
global?: boolean
|
||||||
|
overwriteExisting?: boolean
|
||||||
|
initialAlertsState?: Record<string, Map<string, AlertRecord>>
|
||||||
|
}) {
|
||||||
|
const { name } = alertData
|
||||||
|
|
||||||
|
const singleDescription = alertData.singleDesc?.()
|
||||||
|
|
||||||
|
const [checked, setChecked] = useState(global ? false : !!alert)
|
||||||
|
const [min, setMin] = useState(alert?.min || 10)
|
||||||
|
const [value, setValue] = useState(alert?.value || (singleDescription ? 0 : alertData.start ?? 80))
|
||||||
|
|
||||||
|
const Icon = alertData.icon
|
||||||
|
|
||||||
|
/** Get system ids to update */
|
||||||
|
function getSystemIds(): string[] {
|
||||||
|
// if not global, update only the current system
|
||||||
|
if (!global) {
|
||||||
|
return [system.id]
|
||||||
|
}
|
||||||
|
// if global, update all systems when overwriteExisting is true
|
||||||
|
// update only systems without an existing alert when overwriteExisting is false
|
||||||
|
const allSystems = $systems.get()
|
||||||
|
const systemIds: string[] = []
|
||||||
|
for (const system of allSystems) {
|
||||||
|
if (overwriteExisting || !initialAlertsState[system.id]?.has(alertKey)) {
|
||||||
|
systemIds.push(system.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return systemIds
|
||||||
}
|
}
|
||||||
|
|
||||||
const recordData: Partial<AlertRecord> = {
|
function sendUpsert(min: number, value: number) {
|
||||||
|
const systems = getSystemIds()
|
||||||
|
systems.length &&
|
||||||
|
upsertAlerts({
|
||||||
|
name: alertKey,
|
||||||
value,
|
value,
|
||||||
min,
|
min,
|
||||||
triggered: false,
|
systems,
|
||||||
}
|
|
||||||
|
|
||||||
const batch = batchWrapper("alerts", 25)
|
|
||||||
const systems = $systems.get()
|
|
||||||
const currentAlerts = $alerts.get()
|
|
||||||
|
|
||||||
// map of current alerts with this name right now by system id
|
|
||||||
const currentAlertsSystems = new Map<string, AlertRecord>()
|
|
||||||
for (const alert of currentAlerts) {
|
|
||||||
if (alert.name === data.name) {
|
|
||||||
currentAlertsSystems.set(alert.system, alert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (overwrite) {
|
|
||||||
existingAlertsSystems.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
const processSystem = async (system: SystemRecord): Promise<void> => {
|
|
||||||
const existingAlert = existingAlertsSystems.has(system.id)
|
|
||||||
|
|
||||||
if (!overwrite && existingAlert) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentAlert = currentAlertsSystems.get(system.id)
|
|
||||||
|
|
||||||
// delete existing alert if unchecked
|
|
||||||
if (!checked && currentAlert) {
|
|
||||||
return batch.remove(currentAlert.id)
|
|
||||||
}
|
|
||||||
if (checked && currentAlert) {
|
|
||||||
// update existing alert if checked
|
|
||||||
return batch.update(currentAlert.id, recordData)
|
|
||||||
}
|
|
||||||
if (checked) {
|
|
||||||
// create new alert if checked and not existing
|
|
||||||
return batch.create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.record!.id,
|
|
||||||
name: data.name,
|
|
||||||
...recordData,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// make sure current system is updated in the first batch
|
|
||||||
await processSystem(data.system)
|
|
||||||
for (const system of systems) {
|
|
||||||
if (system.id === data.system.id) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (sem.size() > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await processSystem(system)
|
|
||||||
}
|
|
||||||
await batch.send()
|
|
||||||
} finally {
|
|
||||||
sem.release()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return <AlertContent data={data} />
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a wrapper for performing batch operations on a specified collection.
|
|
||||||
*/
|
|
||||||
function batchWrapper(collection: string, batchSize: number) {
|
|
||||||
let batch: BatchService | undefined
|
|
||||||
let count = 0
|
|
||||||
|
|
||||||
const create = async <T extends Record<string, any>>(options: T) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).create(options)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const update = async <T extends Record<string, any>>(id: string, data: T) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).update(id, data)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const remove = async (id: string) => {
|
|
||||||
batch ||= pb.createBatch()
|
|
||||||
batch.collection(collection).delete(id)
|
|
||||||
if (++count >= batchSize) {
|
|
||||||
await send()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const send = async () => {
|
|
||||||
if (count) {
|
|
||||||
await batch?.send({ requestKey: null })
|
|
||||||
batch = undefined
|
|
||||||
count = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
update,
|
|
||||||
remove,
|
|
||||||
send,
|
|
||||||
create,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function AlertContent({ data }: { data: AlertData }) {
|
|
||||||
const { name } = data
|
|
||||||
|
|
||||||
const singleDescription = data.alert.singleDesc?.()
|
|
||||||
|
|
||||||
const [checked, setChecked] = useState(data.checked || false)
|
|
||||||
const [min, setMin] = useState(data.min || 10)
|
|
||||||
const [value, setValue] = useState(data.val || (singleDescription ? 0 : data.alert.start ?? 80))
|
|
||||||
|
|
||||||
const Icon = alertInfo[name].icon
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
||||||
@@ -231,16 +213,28 @@ function AlertContent({ data }: { data: AlertData }) {
|
|||||||
>
|
>
|
||||||
<div className="grid gap-1 select-none">
|
<div className="grid gap-1 select-none">
|
||||||
<p className="font-semibold flex gap-3 items-center">
|
<p className="font-semibold flex gap-3 items-center">
|
||||||
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
<Icon className="h-4 w-4 opacity-85" /> {alertData.name()}
|
||||||
</p>
|
</p>
|
||||||
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
id={`s${name}`}
|
id={`s${name}`}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onCheckedChange={(newChecked) => {
|
onCheckedChange={(newChecked) => {
|
||||||
setChecked(newChecked)
|
setChecked(newChecked)
|
||||||
data.updateAlert?.(newChecked, value, min)
|
if (newChecked) {
|
||||||
|
// if alert checked, create or update alert
|
||||||
|
sendUpsert(min, value)
|
||||||
|
} else {
|
||||||
|
// if unchecked, delete alert (unless global and overwriteExisting is false)
|
||||||
|
deleteAlerts({ name: alertKey, systems: getSystemIds() })
|
||||||
|
// when force deleting all alerts of a type, also remove them from initialAlertsState
|
||||||
|
if (overwriteExisting) {
|
||||||
|
for (const curAlerts of Object.values(initialAlertsState)) {
|
||||||
|
curAlerts.delete(alertKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
@@ -254,7 +248,7 @@ function AlertContent({ data }: { data: AlertData }) {
|
|||||||
Average exceeds{" "}
|
Average exceeds{" "}
|
||||||
<strong className="text-foreground">
|
<strong className="text-foreground">
|
||||||
{value}
|
{value}
|
||||||
{data.alert.unit}
|
{alertData.unit}
|
||||||
</strong>
|
</strong>
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
@@ -262,15 +256,11 @@ function AlertContent({ data }: { data: AlertData }) {
|
|||||||
<Slider
|
<Slider
|
||||||
aria-labelledby={`v${name}`}
|
aria-labelledby={`v${name}`}
|
||||||
defaultValue={[value]}
|
defaultValue={[value]}
|
||||||
onValueCommit={(val) => {
|
onValueCommit={(val) => sendUpsert(min, val[0])}
|
||||||
data.updateAlert?.(true, val[0], min)
|
onValueChange={(val) => setValue(val[0])}
|
||||||
}}
|
step={alertData.step ?? 1}
|
||||||
onValueChange={(val) => {
|
min={alertData.min ?? 1}
|
||||||
setValue(val[0])
|
max={alertData.max ?? 99}
|
||||||
}}
|
|
||||||
step={data.alert.step ?? 1}
|
|
||||||
min={data.alert.min ?? 1}
|
|
||||||
max={alertInfo[name].max ?? 99}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,12 +282,8 @@ function AlertContent({ data }: { data: AlertData }) {
|
|||||||
<Slider
|
<Slider
|
||||||
aria-labelledby={`v${name}`}
|
aria-labelledby={`v${name}`}
|
||||||
defaultValue={[min]}
|
defaultValue={[min]}
|
||||||
onValueCommit={(min) => {
|
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
|
||||||
data.updateAlert?.(true, value, min[0])
|
onValueChange={(val) => setMin(val[0])}
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setMin(val[0])
|
|
||||||
}}
|
|
||||||
min={1}
|
min={1}
|
||||||
max={60}
|
max={60}
|
||||||
/>
|
/>
|
||||||
|
@@ -4,7 +4,7 @@ import { $alerts, $systems, pb } from "@/lib/stores"
|
|||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { GithubIcon } from "lucide-react"
|
import { GithubIcon } from "lucide-react"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
|
import { alertInfo, getSystemNameFromId, updateRecordList, updateSystemList } from "@/lib/utils"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { AlertRecord, SystemRecord } from "@/types"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { $router, Link } from "../router"
|
import { $router, Link } from "../router"
|
||||||
@@ -14,26 +14,8 @@ import { getPagePath } from "@nanostores/router"
|
|||||||
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
const SystemsTable = lazy(() => import("../systems-table/systems-table"))
|
||||||
|
|
||||||
export const Home = memo(() => {
|
export const Home = memo(() => {
|
||||||
const alerts = useStore($alerts)
|
|
||||||
const systems = useStore($systems)
|
|
||||||
const { t } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
/* key to prevent re-rendering of active alerts */
|
|
||||||
const alertsKey: string[] = []
|
|
||||||
|
|
||||||
const activeAlerts = useMemo(() => {
|
|
||||||
const activeAlerts = alerts.filter((alert) => {
|
|
||||||
const active = alert.triggered && alert.name in alertInfo
|
|
||||||
if (!active) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
alert.sysname = systems.find((system) => system.id === alert.system)?.name
|
|
||||||
alertsKey.push(alert.id)
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
return activeAlerts
|
|
||||||
}, [systems, alerts])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = t`Dashboard` + " / Beszel"
|
document.title = t`Dashboard` + " / Beszel"
|
||||||
}, [t])
|
}, [t])
|
||||||
@@ -46,20 +28,15 @@ export const Home = memo(() => {
|
|||||||
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
|
pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
|
||||||
updateRecordList(e, $systems)
|
updateRecordList(e, $systems)
|
||||||
})
|
})
|
||||||
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
|
|
||||||
updateRecordList(e, $alerts)
|
|
||||||
})
|
|
||||||
return () => {
|
return () => {
|
||||||
pb.collection("systems").unsubscribe("*")
|
pb.collection("systems").unsubscribe("*")
|
||||||
// pb.collection('alerts').unsubscribe('*')
|
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
() => (
|
() => (
|
||||||
<>
|
<>
|
||||||
{/* show active alerts */}
|
<ActiveAlerts />
|
||||||
{activeAlerts.length > 0 && <ActiveAlerts key={activeAlerts.length} activeAlerts={activeAlerts} />}
|
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<SystemsTable />
|
<SystemsTable />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
@@ -83,11 +60,34 @@ export const Home = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[alertsKey.join("")]
|
[]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
|
const ActiveAlerts = () => {
|
||||||
|
const alerts = useStore($alerts)
|
||||||
|
|
||||||
|
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||||
|
const activeAlerts: AlertRecord[] = []
|
||||||
|
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||||
|
const alertsKey: string[] = []
|
||||||
|
|
||||||
|
for (const systemId of Object.keys(alerts)) {
|
||||||
|
for (const alert of alerts[systemId].values()) {
|
||||||
|
if (alert.triggered && alert.name in alertInfo) {
|
||||||
|
activeAlerts.push(alert)
|
||||||
|
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { activeAlerts, alertsKey }
|
||||||
|
}, [alerts])
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
if (activeAlerts.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<Card className="mb-4">
|
<Card className="mb-4">
|
||||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
@@ -109,7 +109,7 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
|
|||||||
>
|
>
|
||||||
<info.icon className="h-4 w-4" />
|
<info.icon className="h-4 w-4" />
|
||||||
<AlertTitle>
|
<AlertTitle>
|
||||||
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
</AlertTitle>
|
</AlertTitle>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
{alert.name === "Status" ? (
|
{alert.name === "Status" ? (
|
||||||
@@ -122,7 +122,7 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
|
|||||||
)}
|
)}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
<Link
|
<Link
|
||||||
href={getPagePath($router, "system", { name: alert.sysname! })}
|
href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
|
||||||
className="absolute inset-0 w-full h-full"
|
className="absolute inset-0 w-full h-full"
|
||||||
aria-label="View system"
|
aria-label="View system"
|
||||||
></Link>
|
></Link>
|
||||||
@@ -134,4 +134,5 @@ const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) =>
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
})
|
}, [alertsKey.join("")])
|
||||||
|
}
|
||||||
|
@@ -178,7 +178,7 @@ const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) =
|
|||||||
|
|
||||||
const sendTestNotification = async () => {
|
const sendTestNotification = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const res = await pb.send("/api/beszel/send-test-notification", { url })
|
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||||
if ("err" in res && !res.err) {
|
if ("err" in res && !res.err) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Test notification sent`,
|
title: t`Test notification sent`,
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
import PocketBase from "pocketbase"
|
import PocketBase from "pocketbase"
|
||||||
import { atom, map, PreinitializedWritableAtom } from "nanostores"
|
import { atom, map } from "nanostores"
|
||||||
import { AlertRecord, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
import { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
|
||||||
import { basePath } from "@/components/router"
|
import { basePath } from "@/components/router"
|
||||||
import { Unit } from "./enums"
|
import { Unit } from "./enums"
|
||||||
|
|
||||||
@@ -11,16 +11,16 @@ export const pb = new PocketBase(basePath)
|
|||||||
export const $authenticated = atom(pb.authStore.isValid)
|
export const $authenticated = atom(pb.authStore.isValid)
|
||||||
|
|
||||||
/** List of system records */
|
/** List of system records */
|
||||||
export const $systems = atom([] as SystemRecord[])
|
export const $systems = atom<SystemRecord[]>([])
|
||||||
|
|
||||||
/** List of alert records */
|
/** Map of alert records by system id and alert name */
|
||||||
export const $alerts = atom([] as AlertRecord[])
|
export const $alerts = map<AlertMap>({})
|
||||||
|
|
||||||
/** SSH public key */
|
/** SSH public key */
|
||||||
export const $publicKey = atom("")
|
export const $publicKey = atom("")
|
||||||
|
|
||||||
/** Chart time period */
|
/** Chart time period */
|
||||||
export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>
|
export const $chartTime = atom<ChartTimes>("1h")
|
||||||
|
|
||||||
/** Whether to display average or max chart values */
|
/** Whether to display average or max chart values */
|
||||||
export const $maxValues = atom(false)
|
export const $maxValues = atom(false)
|
||||||
@@ -43,10 +43,8 @@ export const $userSettings = map<UserSettings>({
|
|||||||
unitNet: Unit.Bytes,
|
unitNet: Unit.Bytes,
|
||||||
unitTemp: Unit.Celsius,
|
unitTemp: Unit.Celsius,
|
||||||
})
|
})
|
||||||
// update local storage on change
|
// update chart time on change
|
||||||
$userSettings.subscribe((value) => {
|
$userSettings.subscribe((value) => $chartTime.set(value.chartTime))
|
||||||
$chartTime.set(value.chartTime)
|
|
||||||
})
|
|
||||||
|
|
||||||
/** Container chart filter */
|
/** Container chart filter */
|
||||||
export const $containerFilter = atom("")
|
export const $containerFilter = atom("")
|
||||||
|
@@ -84,21 +84,13 @@ export const updateSystemList = (() => {
|
|||||||
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
|
||||||
export async function logOut() {
|
export async function logOut() {
|
||||||
$systems.set([])
|
$systems.set([])
|
||||||
$alerts.set([])
|
$alerts.set({})
|
||||||
$userSettings.set({} as UserSettings)
|
$userSettings.set({} as UserSettings)
|
||||||
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
sessionStorage.setItem("lo", "t") // prevent auto login on logout
|
||||||
pb.authStore.clear()
|
pb.authStore.clear()
|
||||||
pb.realtime.unsubscribe()
|
pb.realtime.unsubscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const updateAlerts = () => {
|
|
||||||
pb.collection("alerts")
|
|
||||||
.getFullList<AlertRecord>({ fields: "id,name,system,value,min,triggered", sort: "updated" })
|
|
||||||
.then((records) => {
|
|
||||||
$alerts.set(records)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
|
const hourWithMinutesFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
@@ -439,7 +431,7 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
step: 0.1,
|
step: 0.1,
|
||||||
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
desc: () => t`Triggers when 15 minute load average exceeds a threshold`,
|
||||||
},
|
},
|
||||||
}
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retuns value of system host, truncating full path if socket.
|
* Retuns value of system host, truncating full path if socket.
|
||||||
@@ -513,3 +505,103 @@ export function getMeterState(value: number): MeterState {
|
|||||||
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
|
const { colorWarn = 65, colorCrit = 90 } = $userSettings.get()
|
||||||
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
|
return value >= colorCrit ? MeterState.Crit : value >= colorWarn ? MeterState.Warn : MeterState.Good
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
return (...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => func(...args), wait)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* returns the name of a system from its id */
|
||||||
|
export const getSystemNameFromId = (() => {
|
||||||
|
const cache = new Map<string, string>()
|
||||||
|
return (systemId: string): string => {
|
||||||
|
if (cache.has(systemId)) {
|
||||||
|
return cache.get(systemId)!
|
||||||
|
}
|
||||||
|
const sysName = $systems.get().find((s) => s.id === systemId)?.name ?? ""
|
||||||
|
cache.set(systemId, sysName)
|
||||||
|
return sysName
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
// TODO: reorganize this utils file into more specific files
|
||||||
|
/** Helper to manage user alerts */
|
||||||
|
export const alertManager = (() => {
|
||||||
|
const collection = pb.collection<AlertRecord>("alerts")
|
||||||
|
|
||||||
|
/** Fields to fetch from alerts collection */
|
||||||
|
const fields = "id,name,system,value,min,triggered"
|
||||||
|
|
||||||
|
/** Fetch alerts from collection */
|
||||||
|
async function fetchAlerts(): Promise<AlertRecord[]> {
|
||||||
|
return await collection.getFullList<AlertRecord>({ fields, sort: "updated" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format alerts into a map of system id to alert name to alert record */
|
||||||
|
function add(alerts: AlertRecord[]) {
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const systemId = alert.system
|
||||||
|
const systemAlerts = $alerts.get()[systemId] ?? new Map()
|
||||||
|
const newAlerts = new Map(systemAlerts)
|
||||||
|
newAlerts.set(alert.name, alert)
|
||||||
|
$alerts.setKey(systemId, newAlerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function remove(alerts: Pick<AlertRecord, "name" | "system">[]) {
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const systemId = alert.system
|
||||||
|
const systemAlerts = $alerts.get()[systemId]
|
||||||
|
const newAlerts = new Map(systemAlerts)
|
||||||
|
newAlerts.delete(alert.name)
|
||||||
|
$alerts.setKey(systemId, newAlerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionFns = {
|
||||||
|
create: add,
|
||||||
|
update: add,
|
||||||
|
delete: remove,
|
||||||
|
}
|
||||||
|
|
||||||
|
// batch alert updates to prevent unnecessary re-renders when adding many alerts at once
|
||||||
|
const batchUpdate = (() => {
|
||||||
|
const batch = new Map<string, RecordSubscription<AlertRecord>>()
|
||||||
|
let timeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
|
return (data: RecordSubscription<AlertRecord>) => {
|
||||||
|
const { record } = data
|
||||||
|
batch.set(`${record.system}${record.name}`, data)
|
||||||
|
clearTimeout(timeout!)
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
const groups = { create: [], update: [], delete: [] } as Record<string, AlertRecord[]>
|
||||||
|
for (const { action, record } of batch.values()) {
|
||||||
|
groups[action]?.push(record)
|
||||||
|
}
|
||||||
|
for (const key in groups) {
|
||||||
|
if (groups[key].length) {
|
||||||
|
actionFns[key as keyof typeof actionFns]?.(groups[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batch.clear()
|
||||||
|
}, 50)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
collection.subscribe("*", batchUpdate, { fields })
|
||||||
|
|
||||||
|
return {
|
||||||
|
/** Add alerts to store */
|
||||||
|
add,
|
||||||
|
/** Remove alerts from store */
|
||||||
|
remove,
|
||||||
|
/** Refresh alerts with latest data from hub */
|
||||||
|
async refresh() {
|
||||||
|
const records = await fetchAlerts()
|
||||||
|
add(records)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
@@ -6,7 +6,7 @@ import { Home } from "./components/routes/home.tsx"
|
|||||||
import { ThemeProvider } from "./components/theme-provider.tsx"
|
import { ThemeProvider } from "./components/theme-provider.tsx"
|
||||||
import { DirectionProvider } from "@radix-ui/react-direction"
|
import { DirectionProvider } from "@radix-ui/react-direction"
|
||||||
import { $authenticated, $systems, pb, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
|
import { $authenticated, $systems, pb, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
|
||||||
import { updateUserSettings, updateAlerts, updateFavicon, updateSystemList } from "./lib/utils.ts"
|
import { updateUserSettings, updateFavicon, updateSystemList, alertManager } from "./lib/utils.ts"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { Toaster } from "./components/ui/toaster.tsx"
|
import { Toaster } from "./components/ui/toaster.tsx"
|
||||||
import { $router } from "./components/router.tsx"
|
import { $router } from "./components/router.tsx"
|
||||||
@@ -38,7 +38,7 @@ const App = memo(() => {
|
|||||||
// get servers / alerts / settings
|
// get servers / alerts / settings
|
||||||
updateUserSettings()
|
updateUserSettings()
|
||||||
// get alerts after system list is loaded
|
// get alerts after system list is loaded
|
||||||
updateSystemList().then(updateAlerts)
|
updateSystemList().then(alertManager.refresh)
|
||||||
|
|
||||||
return () => updateFavicon("favicon.svg")
|
return () => updateFavicon("favicon.svg")
|
||||||
}, [])
|
}, [])
|
||||||
|
5
beszel/site/src/types.d.ts
vendored
5
beszel/site/src/types.d.ts
vendored
@@ -196,7 +196,8 @@ export interface AlertRecord extends RecordModel {
|
|||||||
system: string
|
system: string
|
||||||
name: string
|
name: string
|
||||||
triggered: boolean
|
triggered: boolean
|
||||||
sysname?: string
|
value: number
|
||||||
|
min: number
|
||||||
// user: string
|
// user: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,3 +269,5 @@ interface AlertInfo {
|
|||||||
/** Single value description (when there's only one value, like status) */
|
/** Single value description (when there's only one value, like status) */
|
||||||
singleDesc?: () => string
|
singleDesc?: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AlertMap = Record<string, Map<string, AlertRecord>>
|
||||||
|
Reference in New Issue
Block a user