mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 22:16:18 +01:00
Compare commits
8 Commits
v0.12.3
...
bbebb3e301
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbebb3e301 | ||
|
|
9d25181d1d | ||
|
|
7ba1f366ba | ||
|
|
37c6b920f9 | ||
|
|
49db81dac8 | ||
|
|
a9e90ec19c | ||
|
|
2ad60507b7 | ||
|
|
12059ee3db |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,3 +19,4 @@ beszel/site/src/locales/**/*.ts
|
|||||||
__debug_*
|
__debug_*
|
||||||
beszel/internal/agent/lhm/obj
|
beszel/internal/agent/lhm/obj
|
||||||
beszel/internal/agent/lhm/bin
|
beszel/internal/agent/lhm/bin
|
||||||
|
dockerfile_agent_dev
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ func resolveHistoryOnAlertDelete(e *core.RecordEvent) error {
|
|||||||
if !e.Record.GetBool("triggered") {
|
if !e.Record.GetBool("triggered") {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
_ = resolveAlertHistoryRecord(e.App, e.Record)
|
_ = resolveAlertHistoryRecord(e.App, e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,19 +36,19 @@ func updateHistoryOnAlertUpdate(e *core.RecordEvent) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// if new state is not triggered, check for matching alert history record and set it to resolved
|
// if new state is not triggered, check for matching alert history record and set it to resolved
|
||||||
_ = resolveAlertHistoryRecord(e.App, new)
|
_ = resolveAlertHistoryRecord(e.App, new.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAlertHistoryRecord sets the resolved field to the current time
|
// resolveAlertHistoryRecord sets the resolved field to the current time
|
||||||
func resolveAlertHistoryRecord(app core.App, alertRecord *core.Record) error {
|
func resolveAlertHistoryRecord(app core.App, alertRecordID string) error {
|
||||||
alertHistoryRecords, err := app.FindRecordsByFilter(
|
alertHistoryRecords, err := app.FindRecordsByFilter(
|
||||||
"alerts_history",
|
"alerts_history",
|
||||||
"alert_id={:alert_id} && resolved=null",
|
"alert_id={:alert_id} && resolved=null",
|
||||||
"-created",
|
"-created",
|
||||||
1,
|
1,
|
||||||
0,
|
0,
|
||||||
dbx.Params{"alert_id": alertRecord.Id},
|
dbx.Params{"alert_id": alertRecordID},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,6 @@ import (
|
|||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"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/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -279,9 +278,8 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
|
|||||||
|
|
||||||
// Returns the current config.yml file as a JSON object
|
// Returns the current config.yml file as a JSON object
|
||||||
func GetYamlConfig(e *core.RequestEvent) error {
|
func GetYamlConfig(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
if e.Auth.GetString("role") != "admin" {
|
||||||
if info.Auth == nil || info.Auth.GetString("role") != "admin" {
|
return e.ForbiddenError("Requires admin role", nil)
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
}
|
||||||
configContent, err := generateYAML(e.App)
|
configContent, err := generateYAML(e.App)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -224,48 +224,48 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
|
|||||||
|
|
||||||
// custom api routes
|
// custom api routes
|
||||||
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||||
// returns public key and version
|
// auth protected routes
|
||||||
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
|
apiAuth := se.Router.Group("/api/beszel")
|
||||||
info, _ := e.RequestInfo()
|
apiAuth.Bind(apis.RequireAuth())
|
||||||
if info.Auth == nil {
|
// auth optional routes
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
apiNoAuth := se.Router.Group("/api/beszel")
|
||||||
}
|
|
||||||
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
// create first user endpoint only needed if no users exist
|
||||||
})
|
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
|
||||||
|
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
|
||||||
|
}
|
||||||
// check if first time setup on login page
|
// check if first time setup on login page
|
||||||
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
|
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
|
||||||
total, err := h.CountRecords("users")
|
total, err := e.App.CountRecords("users")
|
||||||
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
|
||||||
})
|
})
|
||||||
|
// get public key and version
|
||||||
|
apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
|
||||||
|
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
|
||||||
|
})
|
||||||
// send test notification
|
// send test notification
|
||||||
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
|
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||||
// API endpoint to get config.yml content
|
// get config.yml content
|
||||||
se.Router.GET("/api/beszel/config-yaml", config.GetYamlConfig)
|
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||||
// handle agent websocket connection
|
// handle agent websocket connection
|
||||||
se.Router.GET("/api/beszel/agent-connect", h.handleAgentConnect)
|
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||||
// get or create universal tokens
|
// get or create universal tokens
|
||||||
se.Router.GET("/api/beszel/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
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for universal token API endpoint (create, read, delete)
|
// Handler for universal token API endpoint (create, read, delete)
|
||||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||||
info, err := e.RequestInfo()
|
|
||||||
if err != nil || info.Auth == nil {
|
|
||||||
return apis.NewForbiddenError("Forbidden", nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
tokenMap := universalTokenMap.GetMap()
|
tokenMap := universalTokenMap.GetMap()
|
||||||
userID := info.Auth.Id
|
userID := e.Auth.Id
|
||||||
query := e.Request.URL.Query()
|
query := e.Request.URL.Query()
|
||||||
token := query.Get("token")
|
token := query.Get("token")
|
||||||
tokenSet := token != ""
|
|
||||||
|
|
||||||
if !tokenSet {
|
if token == "" {
|
||||||
// return existing token if it exists
|
// return existing token if it exists
|
||||||
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
if token, _, ok := tokenMap.GetByValue(userID); ok {
|
||||||
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true})
|
||||||
|
|||||||
@@ -4,27 +4,37 @@
|
|||||||
package hub_test
|
package hub_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/tests"
|
beszelTests "beszel/internal/tests"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"bytes"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"encoding/json"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
func getTestHub(t testing.TB) *tests.TestHub {
|
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||||
hub, _ := tests.NewTestHub(t.TempDir())
|
func jsonReader(v any) io.Reader {
|
||||||
return hub
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return bytes.NewReader(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMakeLink(t *testing.T) {
|
func TestMakeLink(t *testing.T) {
|
||||||
hub := getTestHub(t)
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -114,7 +124,7 @@ func TestMakeLink(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestGetSSHKey(t *testing.T) {
|
func TestGetSSHKey(t *testing.T) {
|
||||||
hub := getTestHub(t)
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
|
||||||
// Test Case 1: Key generation (no existing key)
|
// Test Case 1: Key generation (no existing key)
|
||||||
t.Run("KeyGeneration", func(t *testing.T) {
|
t.Run("KeyGeneration", func(t *testing.T) {
|
||||||
@@ -254,3 +264,340 @@ func TestGetSSHKey(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApiRoutesAuthentication(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
// Create test user and get auth token
|
||||||
|
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||||
|
require.NoError(t, err, "Failed to create test user")
|
||||||
|
|
||||||
|
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
|
||||||
|
"email": "admin@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
"role": "admin",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to create admin user")
|
||||||
|
adminUserToken, err := adminUser.NewAuthToken()
|
||||||
|
|
||||||
|
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
|
||||||
|
// "email": "superuser@example.com",
|
||||||
|
// "password": "password123",
|
||||||
|
// })
|
||||||
|
// require.NoError(t, err, "Failed to create superuser")
|
||||||
|
|
||||||
|
userToken, err := user.NewAuthToken()
|
||||||
|
require.NoError(t, err, "Failed to create auth token")
|
||||||
|
|
||||||
|
// Create test system for user-alerts endpoints
|
||||||
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to create test system")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
// Auth Protected Routes - Should require authentication
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - no auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - with auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"sending message"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - with user auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Requires admin"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /config-yaml - with admin auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/config-yaml",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": adminUserToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"test-system"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /universal-token - with auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/universal-token",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"active", "token"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - no auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - with auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE /user-alerts - no auth should fail",
|
||||||
|
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{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "DELETE /user-alerts - with auth should succeed",
|
||||||
|
Method: http.MethodDelete,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"success\":true"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
// Create an alert to delete
|
||||||
|
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"system": system.Id,
|
||||||
|
"user": user.Id,
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Auth Optional Routes - Should work without authentication
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - no auth should fail",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /getkey - with auth should also succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/getkey",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"key\":", "\"v\":"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /first-run - no auth should succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/first-run",
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"firstRun\":false"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /first-run - with auth should also succeed",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/first-run",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"firstRun\":false"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: "/api/beszel/agent-connect",
|
||||||
|
ExpectedStatus: 400,
|
||||||
|
ExpectedContent: []string{},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - invalid auth token should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": "invalid-token",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /user-alerts - invalid auth token should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/user-alerts",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": "invalid-token",
|
||||||
|
},
|
||||||
|
ExpectedStatus: 401,
|
||||||
|
ExpectedContent: []string{"requires valid"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"name": "CPU",
|
||||||
|
"value": 80,
|
||||||
|
"min": 10,
|
||||||
|
"systems": []string{system.Id},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateUserEndpointAvailability(t *testing.T) {
|
||||||
|
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Ensure no users exist
|
||||||
|
userCount, err := hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, userCount, "Should start with no users")
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "POST /create-user - should be available when no users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "firstuser@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"User created"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.Test(t)
|
||||||
|
|
||||||
|
// Verify user was created
|
||||||
|
userCount, err = hub.CountRecords("users")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, 1, userCount, "Should have created one user")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
|
||||||
|
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create a user first
|
||||||
|
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
hub.StartHub()
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario := beszelTests.ApiScenario{
|
||||||
|
Name: "POST /create-user - should not be available when users exist",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/create-user",
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"email": "another@example.com",
|
||||||
|
"password": "password123",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 404,
|
||||||
|
ExpectedContent: []string{"wasn't found"},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
}
|
||||||
|
|
||||||
|
scenario.Test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
309
beszel/internal/tests/api.go
Normal file
309
beszel/internal/tests/api.go
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
package tests
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
pbtests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/hook"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NOTE: This is a copy of https://github.com/pocketbase/pocketbase/blob/master/tests/api.go
|
||||||
|
// with the following changes:
|
||||||
|
// - Removed automatic cleanup of the test app in ApiScenario.Test (Aug 17 2025)
|
||||||
|
|
||||||
|
// ApiScenario defines a single api request test case/scenario.
|
||||||
|
type ApiScenario struct {
|
||||||
|
// Name is the test name.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Method is the HTTP method of the test request to use.
|
||||||
|
Method string
|
||||||
|
|
||||||
|
// URL is the url/path of the endpoint you want to test.
|
||||||
|
URL string
|
||||||
|
|
||||||
|
// Body specifies the body to send with the request.
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// strings.NewReader(`{"title":"abc"}`)
|
||||||
|
Body io.Reader
|
||||||
|
|
||||||
|
// Headers specifies the headers to send with the request (e.g. "Authorization": "abc")
|
||||||
|
Headers map[string]string
|
||||||
|
|
||||||
|
// Delay adds a delay before checking the expectations usually
|
||||||
|
// to ensure that all fired non-awaited go routines have finished
|
||||||
|
Delay time.Duration
|
||||||
|
|
||||||
|
// Timeout specifies how long to wait before cancelling the request context.
|
||||||
|
//
|
||||||
|
// A zero or negative value means that there will be no timeout.
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// expectations
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
// ExpectedStatus specifies the expected response HTTP status code.
|
||||||
|
ExpectedStatus int
|
||||||
|
|
||||||
|
// List of keywords that MUST exist in the response body.
|
||||||
|
//
|
||||||
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||||
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||||
|
ExpectedContent []string
|
||||||
|
|
||||||
|
// List of keywords that MUST NOT exist in the response body.
|
||||||
|
//
|
||||||
|
// Either ExpectedContent or NotExpectedContent must be set if the response body is non-empty.
|
||||||
|
// Leave both fields empty if you want to ensure that the response didn't have any body (e.g. 204).
|
||||||
|
NotExpectedContent []string
|
||||||
|
|
||||||
|
// List of hook events to check whether they were fired or not.
|
||||||
|
//
|
||||||
|
// You can use the wildcard "*" event key if you want to ensure
|
||||||
|
// that no other hook events except those listed have been fired.
|
||||||
|
//
|
||||||
|
// For example:
|
||||||
|
//
|
||||||
|
// map[string]int{ "*": 0 } // no hook events were fired
|
||||||
|
// map[string]int{ "*": 0, "EventA": 2 } // no hook events, except EventA were fired
|
||||||
|
// map[string]int{ "EventA": 2, "EventB": 0 } // ensures that EventA was fired exactly 2 times and EventB exactly 0 times.
|
||||||
|
ExpectedEvents map[string]int
|
||||||
|
|
||||||
|
// test hooks
|
||||||
|
// ---------------------------------------------------------------
|
||||||
|
|
||||||
|
TestAppFactory func(t testing.TB) *pbtests.TestApp
|
||||||
|
BeforeTestFunc func(t testing.TB, app *pbtests.TestApp, e *core.ServeEvent)
|
||||||
|
AfterTestFunc func(t testing.TB, app *pbtests.TestApp, res *http.Response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test executes the test scenario.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func TestListExample(t *testing.T) {
|
||||||
|
// scenario := tests.ApiScenario{
|
||||||
|
// Name: "list example collection",
|
||||||
|
// Method: http.MethodGet,
|
||||||
|
// URL: "/api/collections/example/records",
|
||||||
|
// ExpectedStatus: 200,
|
||||||
|
// ExpectedContent: []string{
|
||||||
|
// `"totalItems":3`,
|
||||||
|
// `"id":"0yxhwia2amd8gec"`,
|
||||||
|
// `"id":"achvryl401bhse3"`,
|
||||||
|
// `"id":"llvuca81nly1qls"`,
|
||||||
|
// },
|
||||||
|
// ExpectedEvents: map[string]int{
|
||||||
|
// "OnRecordsListRequest": 1,
|
||||||
|
// "OnRecordEnrich": 3,
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// scenario.Test(t)
|
||||||
|
// }
|
||||||
|
func (scenario *ApiScenario) Test(t *testing.T) {
|
||||||
|
t.Run(scenario.normalizedName(), func(t *testing.T) {
|
||||||
|
scenario.test(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark benchmarks the test scenario.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
//
|
||||||
|
// func BenchmarkListExample(b *testing.B) {
|
||||||
|
// scenario := tests.ApiScenario{
|
||||||
|
// Name: "list example collection",
|
||||||
|
// Method: http.MethodGet,
|
||||||
|
// URL: "/api/collections/example/records",
|
||||||
|
// ExpectedStatus: 200,
|
||||||
|
// ExpectedContent: []string{
|
||||||
|
// `"totalItems":3`,
|
||||||
|
// `"id":"0yxhwia2amd8gec"`,
|
||||||
|
// `"id":"achvryl401bhse3"`,
|
||||||
|
// `"id":"llvuca81nly1qls"`,
|
||||||
|
// },
|
||||||
|
// ExpectedEvents: map[string]int{
|
||||||
|
// "OnRecordsListRequest": 1,
|
||||||
|
// "OnRecordEnrich": 3,
|
||||||
|
// },
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// scenario.Benchmark(b)
|
||||||
|
// }
|
||||||
|
func (scenario *ApiScenario) Benchmark(b *testing.B) {
|
||||||
|
b.Run(scenario.normalizedName(), func(b *testing.B) {
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
scenario.test(b)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scenario *ApiScenario) normalizedName() string {
|
||||||
|
var name = scenario.Name
|
||||||
|
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("%s:%s", scenario.Method, scenario.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
func (scenario *ApiScenario) test(t testing.TB) {
|
||||||
|
var testApp *pbtests.TestApp
|
||||||
|
if scenario.TestAppFactory != nil {
|
||||||
|
testApp = scenario.TestAppFactory(t)
|
||||||
|
if testApp == nil {
|
||||||
|
t.Fatal("TestAppFactory must return a non-nill app instance")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
var testAppErr error
|
||||||
|
testApp, testAppErr = pbtests.NewTestApp()
|
||||||
|
if testAppErr != nil {
|
||||||
|
t.Fatalf("Failed to initialize the test app instance: %v", testAppErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// defer testApp.Cleanup()
|
||||||
|
|
||||||
|
baseRouter, err := apis.NewRouter(testApp)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// manually trigger the serve event to ensure that custom app routes and middlewares are registered
|
||||||
|
serveEvent := new(core.ServeEvent)
|
||||||
|
serveEvent.App = testApp
|
||||||
|
serveEvent.Router = baseRouter
|
||||||
|
|
||||||
|
serveErr := testApp.OnServe().Trigger(serveEvent, func(e *core.ServeEvent) error {
|
||||||
|
if scenario.BeforeTestFunc != nil {
|
||||||
|
scenario.BeforeTestFunc(t, testApp, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset the event counters in case a hook was triggered from a before func (eg. db save)
|
||||||
|
testApp.ResetEventCalls()
|
||||||
|
|
||||||
|
// add middleware to timeout long-running requests (eg. keep-alive routes)
|
||||||
|
e.Router.Bind(&hook.Handler[*core.RequestEvent]{
|
||||||
|
Func: func(re *core.RequestEvent) error {
|
||||||
|
slowTimer := time.AfterFunc(3*time.Second, func() {
|
||||||
|
t.Logf("[WARN] Long running test %q", scenario.Name)
|
||||||
|
})
|
||||||
|
defer slowTimer.Stop()
|
||||||
|
|
||||||
|
if scenario.Timeout > 0 {
|
||||||
|
ctx, cancelFunc := context.WithTimeout(re.Request.Context(), scenario.Timeout)
|
||||||
|
defer cancelFunc()
|
||||||
|
re.Request = re.Request.Clone(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return re.Next()
|
||||||
|
},
|
||||||
|
Priority: -9999,
|
||||||
|
})
|
||||||
|
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(scenario.Method, scenario.URL, scenario.Body)
|
||||||
|
|
||||||
|
// set default header
|
||||||
|
req.Header.Set("content-type", "application/json")
|
||||||
|
|
||||||
|
// set scenario headers
|
||||||
|
for k, v := range scenario.Headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute request
|
||||||
|
mux, err := e.Router.BuildMux()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to build router mux: %v", err)
|
||||||
|
}
|
||||||
|
mux.ServeHTTP(recorder, req)
|
||||||
|
|
||||||
|
res := recorder.Result()
|
||||||
|
|
||||||
|
if res.StatusCode != scenario.ExpectedStatus {
|
||||||
|
t.Errorf("Expected status code %d, got %d", scenario.ExpectedStatus, res.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario.Delay > 0 {
|
||||||
|
time.Sleep(scenario.Delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(scenario.ExpectedContent) == 0 && len(scenario.NotExpectedContent) == 0 {
|
||||||
|
if len(recorder.Body.Bytes()) != 0 {
|
||||||
|
t.Errorf("Expected empty body, got \n%v", recorder.Body.String())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// normalize json response format
|
||||||
|
buffer := new(bytes.Buffer)
|
||||||
|
err := json.Compact(buffer, recorder.Body.Bytes())
|
||||||
|
var normalizedBody string
|
||||||
|
if err != nil {
|
||||||
|
// not a json...
|
||||||
|
normalizedBody = recorder.Body.String()
|
||||||
|
} else {
|
||||||
|
normalizedBody = buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range scenario.ExpectedContent {
|
||||||
|
if !strings.Contains(normalizedBody, item) {
|
||||||
|
t.Errorf("Cannot find %v in response body \n%v", item, normalizedBody)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range scenario.NotExpectedContent {
|
||||||
|
if strings.Contains(normalizedBody, item) {
|
||||||
|
t.Errorf("Didn't expect %v in response body \n%v", item, normalizedBody)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingEvents := maps.Clone(testApp.EventCalls)
|
||||||
|
|
||||||
|
var noOtherEventsShouldRemain bool
|
||||||
|
for event, expectedNum := range scenario.ExpectedEvents {
|
||||||
|
if event == "*" && expectedNum <= 0 {
|
||||||
|
noOtherEventsShouldRemain = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
actualNum := remainingEvents[event]
|
||||||
|
if actualNum != expectedNum {
|
||||||
|
t.Errorf("Expected event %s to be called %d, got %d", event, expectedNum, actualNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(remainingEvents, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
if noOtherEventsShouldRemain && len(remainingEvents) > 0 {
|
||||||
|
t.Errorf("Missing expected remaining events:\n%#v\nAll triggered app events are:\n%#v", remainingEvents, testApp.EventCalls)
|
||||||
|
}
|
||||||
|
|
||||||
|
if scenario.AfterTestFunc != nil {
|
||||||
|
scenario.AfterTestFunc(t, testApp, res)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if serveErr != nil {
|
||||||
|
t.Fatalf("Failed to trigger app serve hook: %v", serveErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,40 +1,28 @@
|
|||||||
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(
|
||||||
() => (
|
() => (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
<Button variant="ghost" size="icon" aria-label={t`Alerts`} onClick={() => setOpened(true)}>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
className={cn("h-[1.2em] w-[1.2em]", {
|
||||||
"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])
|
|
||||||
}
|
|
||||||
|
|||||||
297
beszel/site/src/components/alerts/alerts-dialog.tsx
Normal file
297
beszel/site/src/components/alerts/alerts-dialog.tsx
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Trans, Plural } from "@lingui/react/macro"
|
||||||
|
import { $alerts, $systems, pb } from "@/lib/stores"
|
||||||
|
import { alertInfo, cn, debounce } from "@/lib/utils"
|
||||||
|
import { Switch } from "@/components/ui/switch"
|
||||||
|
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
||||||
|
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||||
|
import { toast } from "@/components/ui/use-toast"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import { DialogTitle, DialogDescription } from "@/components/ui/dialog"
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"
|
||||||
|
import { ServerIcon, GlobeIcon } from "lucide-react"
|
||||||
|
import { $router, Link } from "@/components/router"
|
||||||
|
import { DialogHeader } from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
const Slider = lazy(() => import("@/components/ui/slider"))
|
||||||
|
|
||||||
|
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({
|
||||||
|
title: t`Failed to update alert`,
|
||||||
|
description: t`Please check logs for more details.`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create or update alerts for a given name and systems */
|
||||||
|
const upsertAlerts = debounce(
|
||||||
|
async ({ name, value, min, systems }: { name: string; value: number; min: number; systems: string[] }) => {
|
||||||
|
try {
|
||||||
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
// overwrite is always true because we've done filtering client side
|
||||||
|
body: { name, value, min, systems, overwrite: true },
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
failedUpdateToast(error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
alertDebounce
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Delete alerts for a given name and systems */
|
||||||
|
const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems: string[] }) => {
|
||||||
|
try {
|
||||||
|
await pb.send<{ success: boolean }>(endpoint, {
|
||||||
|
method: "DELETE",
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendUpsert(min: number, value: number) {
|
||||||
|
const systems = getSystemIds()
|
||||||
|
systems.length &&
|
||||||
|
upsertAlerts({
|
||||||
|
name: alertKey,
|
||||||
|
value,
|
||||||
|
min,
|
||||||
|
systems,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
||||||
|
<label
|
||||||
|
htmlFor={`s${name}`}
|
||||||
|
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
||||||
|
"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" /> {alertData.name()}
|
||||||
|
</p>
|
||||||
|
{!checked && <span className="block text-sm text-muted-foreground">{alertData.desc()}</span>}
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id={`s${name}`}
|
||||||
|
checked={checked}
|
||||||
|
onCheckedChange={(newChecked) => {
|
||||||
|
setChecked(newChecked)
|
||||||
|
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>
|
||||||
|
{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${name}`} className="text-sm block h-8">
|
||||||
|
<Trans>
|
||||||
|
Average exceeds{" "}
|
||||||
|
<strong className="text-foreground">
|
||||||
|
{value}
|
||||||
|
{alertData.unit}
|
||||||
|
</strong>
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${name}`}
|
||||||
|
defaultValue={[value]}
|
||||||
|
onValueCommit={(val) => sendUpsert(min, val[0])}
|
||||||
|
onValueChange={(val) => setValue(val[0])}
|
||||||
|
step={alertData.step ?? 1}
|
||||||
|
min={alertData.min ?? 1}
|
||||||
|
max={alertData.max ?? 99}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
||||||
|
<p id={`t${name}`} 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" />
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Slider
|
||||||
|
aria-labelledby={`v${name}`}
|
||||||
|
defaultValue={[min]}
|
||||||
|
onValueCommit={(minVal) => sendUpsert(minVal[0], value)}
|
||||||
|
onValueChange={(val) => setMin(val[0])}
|
||||||
|
min={1}
|
||||||
|
max={60}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
|
||||||
import { Trans, Plural } from "@lingui/react/macro"
|
|
||||||
import { $alerts, $systems, pb } from "@/lib/stores"
|
|
||||||
import { alertInfo, cn } from "@/lib/utils"
|
|
||||||
import { Switch } from "@/components/ui/switch"
|
|
||||||
import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
|
|
||||||
import { lazy, Suspense, useMemo, useState } from "react"
|
|
||||||
import { toast } from "../ui/use-toast"
|
|
||||||
import { BatchService } from "pocketbase"
|
|
||||||
import { getSemaphore } from "@henrygd/semaphore"
|
|
||||||
|
|
||||||
interface AlertData {
|
|
||||||
checked?: boolean
|
|
||||||
val?: number
|
|
||||||
min?: number
|
|
||||||
updateAlert?: (checked: boolean, value: number, min: number) => void
|
|
||||||
name: keyof typeof alertInfo
|
|
||||||
alert: AlertInfo
|
|
||||||
system: SystemRecord
|
|
||||||
}
|
|
||||||
|
|
||||||
const Slider = lazy(() => import("@/components/ui/slider"))
|
|
||||||
|
|
||||||
const failedUpdateToast = () =>
|
|
||||||
toast({
|
|
||||||
title: t`Failed to update alert`,
|
|
||||||
description: t`Please check logs for more details.`,
|
|
||||||
variant: "destructive",
|
|
||||||
})
|
|
||||||
|
|
||||||
export function SystemAlert({
|
|
||||||
system,
|
|
||||||
systemAlerts,
|
|
||||||
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 {
|
|
||||||
if (alert && !checked) {
|
|
||||||
await pb.collection("alerts").delete(alert.id)
|
|
||||||
} else if (alert && checked) {
|
|
||||||
await pb.collection("alerts").update(alert.id, { value, min, triggered: false })
|
|
||||||
} else if (checked) {
|
|
||||||
pb.collection("alerts").create({
|
|
||||||
system: system.id,
|
|
||||||
user: pb.authStore.record!.id,
|
|
||||||
name: data.name,
|
|
||||||
value: value,
|
|
||||||
min: min,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
failedUpdateToast()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alert) {
|
|
||||||
data.checked = true
|
|
||||||
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 {
|
|
||||||
// if another update is waiting behind, don't start this one
|
|
||||||
if (sem.size() > 1) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const recordData: Partial<AlertRecord> = {
|
|
||||||
value,
|
|
||||||
min,
|
|
||||||
triggered: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
|
|
||||||
<label
|
|
||||||
htmlFor={`s${name}`}
|
|
||||||
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
|
|
||||||
"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>
|
|
||||||
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id={`s${name}`}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={(newChecked) => {
|
|
||||||
setChecked(newChecked)
|
|
||||||
data.updateAlert?.(newChecked, value, min)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{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${name}`} className="text-sm block h-8">
|
|
||||||
<Trans>
|
|
||||||
Average exceeds{" "}
|
|
||||||
<strong className="text-foreground">
|
|
||||||
{value}
|
|
||||||
{data.alert.unit}
|
|
||||||
</strong>
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Slider
|
|
||||||
aria-labelledby={`v${name}`}
|
|
||||||
defaultValue={[value]}
|
|
||||||
onValueCommit={(val) => {
|
|
||||||
data.updateAlert?.(true, val[0], min)
|
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setValue(val[0])
|
|
||||||
}}
|
|
||||||
step={data.alert.step ?? 1}
|
|
||||||
min={data.alert.min ?? 1}
|
|
||||||
max={alertInfo[name].max ?? 99}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
|
||||||
<p id={`t${name}`} 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" />
|
|
||||||
</Trans>
|
|
||||||
</p>
|
|
||||||
<div className="flex gap-3">
|
|
||||||
<Slider
|
|
||||||
aria-labelledby={`v${name}`}
|
|
||||||
defaultValue={[min]}
|
|
||||||
onValueCommit={(min) => {
|
|
||||||
data.updateAlert?.(true, value, min[0])
|
|
||||||
}}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
setMin(val[0])
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -29,41 +29,36 @@ export default memo(function ContainerChart({
|
|||||||
const isNetChart = chartType === ChartType.Network
|
const isNetChart = chartType === ChartType.Network
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
const config = {} as Record<string, { label: string; color: string }>
|
||||||
string,
|
const totalUsage = new Map<string, number>()
|
||||||
{
|
|
||||||
label: string
|
// calculate total usage of each container
|
||||||
color: string
|
for (const stats of containerData) {
|
||||||
}
|
for (const key in stats) {
|
||||||
>
|
if (!key || key === "created") continue
|
||||||
const totalUsage = {} as Record<string, number>
|
|
||||||
for (let stats of containerData) {
|
const currentTotal = totalUsage.get(key) ?? 0
|
||||||
for (let key in stats) {
|
const increment = isNetChart
|
||||||
if (!key || key === "created") {
|
? (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
|
||||||
continue
|
: // @ts-ignore
|
||||||
}
|
stats[key]?.[dataKey] ?? 0
|
||||||
if (!(key in totalUsage)) {
|
|
||||||
totalUsage[key] = 0
|
totalUsage.set(key, currentTotal + increment)
|
||||||
}
|
|
||||||
if (isNetChart) {
|
|
||||||
totalUsage[key] += (stats[key]?.nr ?? 0) + (stats[key]?.ns ?? 0)
|
|
||||||
} else {
|
|
||||||
// @ts-ignore
|
|
||||||
totalUsage[key] += stats[key]?.[dataKey] ?? 0
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let keys = Object.keys(totalUsage)
|
|
||||||
keys.sort((a, b) => (totalUsage[a] > totalUsage[b] ? -1 : 1))
|
// Sort keys and generate colors based on usage
|
||||||
const length = keys.length
|
const sortedEntries = Array.from(totalUsage.entries()).sort(([, a], [, b]) => b - a)
|
||||||
for (let i = 0; i < length; i++) {
|
|
||||||
const key = keys[i]
|
const length = sortedEntries.length
|
||||||
|
sortedEntries.forEach(([key], i) => {
|
||||||
const hue = ((i * 360) / length) % 360
|
const hue = ((i * 360) / length) % 360
|
||||||
config[key] = {
|
config[key] = {
|
||||||
label: key,
|
label: key,
|
||||||
color: `hsl(${hue}, 60%, 55%)`,
|
color: `hsl(${hue}, 60%, 55%)`,
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
|
|
||||||
return config satisfies ChartConfig
|
return config satisfies ChartConfig
|
||||||
}, [chartData])
|
}, [chartData])
|
||||||
|
|
||||||
@@ -124,6 +119,8 @@ export default memo(function ContainerChart({
|
|||||||
return obj
|
return obj
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const filterLower = filter?.toLowerCase()
|
||||||
|
|
||||||
// console.log('rendered at', new Date())
|
// console.log('rendered at', new Date())
|
||||||
|
|
||||||
if (containerData.length === 0) {
|
if (containerData.length === 0) {
|
||||||
@@ -165,7 +162,7 @@ export default memo(function ContainerChart({
|
|||||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => {
|
{Object.keys(chartConfig).map((key) => {
|
||||||
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
const filtered = filterLower && !key.toLowerCase().includes(filterLower)
|
||||||
let fillOpacity = filtered ? 0.05 : 0.4
|
let fillOpacity = filtered ? 0.05 : 0.4
|
||||||
let strokeOpacity = filtered ? 0.1 : 1
|
let strokeOpacity = filtered ? 0.1 : 1
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -35,18 +35,20 @@ export const navigate = (urlString: string) => {
|
|||||||
$router.open(urlString)
|
$router.open(urlString)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(e: React.MouseEvent<HTMLAnchorElement, MouseEvent>) {
|
export function Link(props: React.AnchorHTMLAttributes<HTMLAnchorElement>) {
|
||||||
e.preventDefault()
|
return (
|
||||||
$router.open(new URL((e.currentTarget as HTMLAnchorElement).href).pathname)
|
<a
|
||||||
}
|
{...props}
|
||||||
|
onClick={(e) => {
|
||||||
export const Link = (props: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
e.preventDefault()
|
||||||
let clickFn = onClick
|
const href = props.href || ""
|
||||||
if (props.onClick) {
|
if (e.ctrlKey || e.metaKey) {
|
||||||
clickFn = (e) => {
|
window.open(href, "_blank")
|
||||||
onClick(e)
|
} else {
|
||||||
props.onClick?.(e)
|
$router.open(href)
|
||||||
}
|
props.onClick?.(e)
|
||||||
}
|
}
|
||||||
return <a {...props} onClick={clickFn}></a>
|
}}
|
||||||
|
></a>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,55 +60,79 @@ export const Home = memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
[alertsKey.join("")]
|
[]
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
|
const ActiveAlerts = () => {
|
||||||
return (
|
const alerts = useStore($alerts)
|
||||||
<Card className="mb-4">
|
|
||||||
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
const { activeAlerts, alertsKey } = useMemo(() => {
|
||||||
<div className="px-2 sm:px-1">
|
const activeAlerts: AlertRecord[] = []
|
||||||
<CardTitle>
|
// key to prevent re-rendering if alerts change but active alerts didn't
|
||||||
<Trans>Active Alerts</Trans>
|
const alertsKey: string[] = []
|
||||||
</CardTitle>
|
|
||||||
</div>
|
for (const systemId of Object.keys(alerts)) {
|
||||||
</CardHeader>
|
for (const alert of alerts[systemId].values()) {
|
||||||
<CardContent className="max-sm:p-2">
|
if (alert.triggered && alert.name in alertInfo) {
|
||||||
{activeAlerts.length > 0 && (
|
activeAlerts.push(alert)
|
||||||
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
alertsKey.push(`${alert.system}${alert.value}${alert.min}`)
|
||||||
{activeAlerts.map((alert) => {
|
}
|
||||||
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
}
|
||||||
return (
|
}
|
||||||
<Alert
|
|
||||||
key={alert.id}
|
return { activeAlerts, alertsKey }
|
||||||
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
|
}, [alerts])
|
||||||
>
|
|
||||||
<info.icon className="h-4 w-4" />
|
return useMemo(() => {
|
||||||
<AlertTitle>
|
if (activeAlerts.length === 0) {
|
||||||
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
|
return null
|
||||||
</AlertTitle>
|
}
|
||||||
<AlertDescription>
|
return (
|
||||||
{alert.name === "Status" ? (
|
<Card className="mb-4">
|
||||||
<Trans>Connection is down</Trans>
|
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
|
||||||
) : (
|
<div className="px-2 sm:px-1">
|
||||||
<Trans>
|
<CardTitle>
|
||||||
Exceeds {alert.value}
|
<Trans>Active Alerts</Trans>
|
||||||
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
</CardTitle>
|
||||||
</Trans>
|
|
||||||
)}
|
|
||||||
</AlertDescription>
|
|
||||||
<Link
|
|
||||||
href={getPagePath($router, "system", { name: alert.sysname! })}
|
|
||||||
className="absolute inset-0 w-full h-full"
|
|
||||||
aria-label="View system"
|
|
||||||
></Link>
|
|
||||||
</Alert>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent className="max-sm:p-2">
|
||||||
</Card>
|
{activeAlerts.length > 0 && (
|
||||||
)
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
|
||||||
})
|
{activeAlerts.map((alert) => {
|
||||||
|
const info = alertInfo[alert.name as keyof typeof alertInfo]
|
||||||
|
return (
|
||||||
|
<Alert
|
||||||
|
key={alert.id}
|
||||||
|
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
|
||||||
|
>
|
||||||
|
<info.icon className="h-4 w-4" />
|
||||||
|
<AlertTitle>
|
||||||
|
{getSystemNameFromId(alert.system)} {info.name().toLowerCase().replace("cpu", "CPU")}
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{alert.name === "Status" ? (
|
||||||
|
<Trans>Connection is down</Trans>
|
||||||
|
) : (
|
||||||
|
<Trans>
|
||||||
|
Exceeds {alert.value}
|
||||||
|
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
|
||||||
|
</Trans>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { name: getSystemNameFromId(alert.system) })}
|
||||||
|
className="absolute inset-0 w-full h-full"
|
||||||
|
aria-label="View system"
|
||||||
|
></Link>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</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`,
|
||||||
|
|||||||
@@ -56,8 +56,8 @@ export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
|
|||||||
href={item.href}
|
href={item.href}
|
||||||
className={cn(
|
className={cn(
|
||||||
buttonVariants({ variant: "ghost" }),
|
buttonVariants({ variant: "ghost" }),
|
||||||
"flex items-center gap-3 justify-start truncate",
|
"flex items-center gap-3 justify-start truncate duration-50",
|
||||||
page?.path === item.href ? "bg-muted hover:bg-muted" : "hover:bg-muted/50"
|
page?.path === item.href ? "bg-muted hover:bg-accent/70" : "hover:bg-accent/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.icon && <item.icon className="size-4 shrink-0" />}
|
{item.icon && <item.icon className="size-4 shrink-0" />}
|
||||||
|
|||||||
@@ -456,9 +456,9 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
onClick={() => setGrid(!grid)}
|
onClick={() => setGrid(!grid)}
|
||||||
>
|
>
|
||||||
{grid ? (
|
{grid ? (
|
||||||
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-85" />
|
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
|
||||||
) : (
|
) : (
|
||||||
<Rows className="h-[1.3rem] w-[1.3rem] opacity-85" />
|
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
|
|||||||
@@ -55,17 +55,21 @@ import {
|
|||||||
import { buttonVariants } from "../ui/button"
|
import { buttonVariants } from "../ui/button"
|
||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { MeterState } from "@/lib/enums"
|
import { MeterState } from "@/lib/enums"
|
||||||
|
import { $router, Link } from "../router"
|
||||||
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
up: "bg-green-500",
|
||||||
|
down: "bg-red-500",
|
||||||
|
paused: "bg-primary/40",
|
||||||
|
pending: "bg-yellow-500",
|
||||||
|
} as const
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param viewMode - "table" or "grid"
|
* @param viewMode - "table" or "grid"
|
||||||
* @returns - Column definitions for the systems table
|
* @returns - Column definitions for the systems table
|
||||||
*/
|
*/
|
||||||
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<SystemRecord>[] {
|
||||||
const statusTranslations = {
|
|
||||||
up: () => t`Up`.toLowerCase(),
|
|
||||||
down: () => t`Down`.toLowerCase(),
|
|
||||||
paused: () => t`Paused`.toLowerCase(),
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
size: 200,
|
size: 200,
|
||||||
@@ -73,27 +77,57 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
accessorKey: "name",
|
accessorKey: "name",
|
||||||
id: "system",
|
id: "system",
|
||||||
name: () => t`System`,
|
name: () => t`System`,
|
||||||
filterFn: (row, _, filterVal) => {
|
filterFn: (() => {
|
||||||
const filterLower = filterVal.toLowerCase()
|
let filterInput = ""
|
||||||
const { name, status } = row.original
|
let filterInputLower = ""
|
||||||
// Check if the filter matches the name or status for this row
|
const nameCache = new Map<string, string>()
|
||||||
if (
|
const statusTranslations = {
|
||||||
name.toLowerCase().includes(filterLower) ||
|
up: t`Up`.toLowerCase(),
|
||||||
statusTranslations[status as keyof typeof statusTranslations]?.().includes(filterLower)
|
down: t`Down`.toLowerCase(),
|
||||||
) {
|
paused: t`Paused`.toLowerCase(),
|
||||||
return true
|
} as const
|
||||||
|
|
||||||
|
// match filter value against name or translated status
|
||||||
|
return (row, _, newFilterInput) => {
|
||||||
|
const { name, status } = row.original
|
||||||
|
if (newFilterInput !== filterInput) {
|
||||||
|
filterInput = newFilterInput
|
||||||
|
filterInputLower = newFilterInput.toLowerCase()
|
||||||
|
}
|
||||||
|
let nameLower = nameCache.get(name)
|
||||||
|
if (nameLower === undefined) {
|
||||||
|
nameLower = name.toLowerCase()
|
||||||
|
nameCache.set(name, nameLower)
|
||||||
|
}
|
||||||
|
if (nameLower.includes(filterInputLower)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
const statusLower = statusTranslations[status as keyof typeof statusTranslations]
|
||||||
|
return statusLower?.includes(filterInputLower) || false
|
||||||
}
|
}
|
||||||
return false
|
})(),
|
||||||
},
|
|
||||||
enableHiding: false,
|
enableHiding: false,
|
||||||
invertSorting: false,
|
invertSorting: false,
|
||||||
Icon: ServerIcon,
|
Icon: ServerIcon,
|
||||||
cell: (info) => (
|
cell: (info) => {
|
||||||
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
|
const { name } = info.row.original
|
||||||
<IndicatorDot system={info.row.original} />
|
return useMemo(
|
||||||
{info.getValue() as string}
|
() => (
|
||||||
</span>
|
<>
|
||||||
),
|
<span className="flex gap-2 items-center font-medium text-sm text-nowrap md:ps-1 md:pe-5">
|
||||||
|
<IndicatorDot system={info.row.original} />
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
<Link
|
||||||
|
href={getPagePath($router, "system", { name })}
|
||||||
|
className="inset-0 absolute size-full"
|
||||||
|
aria-label={name}
|
||||||
|
></Link>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
[name]
|
||||||
|
)
|
||||||
|
},
|
||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -166,9 +200,10 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
<div className="flex items-center gap-[.35em] w-full tabular-nums tracking-tight">
|
||||||
<span
|
<span
|
||||||
className={cn("inline-block size-2 rounded-full me-0.5", {
|
className={cn("inline-block size-2 rounded-full me-0.5", {
|
||||||
"bg-green-500": threshold === MeterState.Good,
|
[STATUS_COLORS.up]: threshold === MeterState.Good,
|
||||||
"bg-yellow-500": threshold === MeterState.Warn,
|
[STATUS_COLORS.pending]: threshold === MeterState.Warn,
|
||||||
"bg-red-600": threshold === MeterState.Crit,
|
[STATUS_COLORS.down]: threshold === MeterState.Crit,
|
||||||
|
[STATUS_COLORS.paused]: status !== "up",
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
{loadAverages?.map((la, i) => (
|
{loadAverages?.map((la, i) => (
|
||||||
@@ -187,10 +222,10 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
cell(info) {
|
cell(info) {
|
||||||
const sys = info.row.original
|
const sys = info.row.original
|
||||||
|
const userSettings = useStore($userSettings, { keys: ["unitNet"] })
|
||||||
if (sys.status === "paused") {
|
if (sys.status === "paused") {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
const { value, unit } = formatBytes(info.getValue() as number, true, userSettings.unitNet, false)
|
||||||
return (
|
return (
|
||||||
<span className="tabular-nums whitespace-nowrap">
|
<span className="tabular-nums whitespace-nowrap">
|
||||||
@@ -209,10 +244,10 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
header: sortableHeader,
|
header: sortableHeader,
|
||||||
cell(info) {
|
cell(info) {
|
||||||
const val = info.getValue() as number
|
const val = info.getValue() as number
|
||||||
|
const userSettings = useStore($userSettings, { keys: ["unitTemp"] })
|
||||||
if (!val) {
|
if (!val) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const userSettings = useStore($userSettings)
|
|
||||||
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
|
||||||
return (
|
return (
|
||||||
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
<span className={cn("tabular-nums whitespace-nowrap", viewMode === "table" && "ps-0.5")}>
|
||||||
@@ -241,9 +276,9 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
<IndicatorDot
|
<IndicatorDot
|
||||||
system={system}
|
system={system}
|
||||||
className={
|
className={
|
||||||
(system.status !== "up" && "bg-primary/30") ||
|
(system.status !== "up" && STATUS_COLORS.paused) ||
|
||||||
(version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
|
(version === globalThis.BESZEL.HUB_VERSION && STATUS_COLORS.up) ||
|
||||||
"bg-yellow-500"
|
STATUS_COLORS.pending
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
<span className="truncate max-w-14">{info.getValue() as string}</span>
|
||||||
@@ -257,7 +292,7 @@ export default function SystemsTableColumns(viewMode: "table" | "grid"): ColumnD
|
|||||||
name: () => t({ message: "Actions", comment: "Table column" }),
|
name: () => t({ message: "Actions", comment: "Table column" }),
|
||||||
size: 50,
|
size: 50,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<div className="flex justify-end items-center gap-1 -ms-3">
|
<div className="relative z-10 flex justify-end items-center gap-1 -ms-3">
|
||||||
<AlertButton system={row.original} />
|
<AlertButton system={row.original} />
|
||||||
<ActionsButton system={row.original} />
|
<ActionsButton system={row.original} />
|
||||||
</div>
|
</div>
|
||||||
@@ -293,10 +328,10 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute inset-0 w-full h-full origin-left",
|
"absolute inset-0 w-full h-full origin-left",
|
||||||
(info.row.original.status !== "up" && "bg-primary/30") ||
|
(info.row.original.status !== "up" && STATUS_COLORS.paused) ||
|
||||||
(threshold === MeterState.Good && "bg-green-500") ||
|
(threshold === MeterState.Good && STATUS_COLORS.up) ||
|
||||||
(threshold === MeterState.Warn && "bg-yellow-500") ||
|
(threshold === MeterState.Warn && STATUS_COLORS.pending) ||
|
||||||
"bg-red-600"
|
STATUS_COLORS.down
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: `scalex(${val / 100})`,
|
transform: `scalex(${val / 100})`,
|
||||||
@@ -308,12 +343,7 @@ function TableCellWithMeter(info: CellContext<SystemRecord, unknown>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
export function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
|
||||||
className ||= {
|
className ||= STATUS_COLORS[system.status as keyof typeof STATUS_COLORS] || ""
|
||||||
"bg-green-500": system.status === "up",
|
|
||||||
"bg-red-500": system.status === "down",
|
|
||||||
"bg-primary/40": system.status === "paused",
|
|
||||||
"bg-yellow-500": system.status === "pending",
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
className={cn("flex-shrink-0 size-2 rounded-full", className)}
|
||||||
@@ -334,7 +364,7 @@ export const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
|
|||||||
<>
|
<>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size={"icon"} data-nolink>
|
<Button variant="ghost" size={"icon"}>
|
||||||
<span className="sr-only">
|
<span className="sr-only">
|
||||||
<Trans>Open menu</Trans>
|
<Trans>Open menu</Trans>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { memo, useEffect, useMemo, useState } from "react"
|
|||||||
import { $systems } from "@/lib/stores"
|
import { $systems } from "@/lib/stores"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { cn, useLocalStorage } from "@/lib/utils"
|
import { cn, useLocalStorage } from "@/lib/utils"
|
||||||
import { $router, Link, navigate } from "../router"
|
import { $router, Link } from "../router"
|
||||||
import { useLingui, Trans } from "@lingui/react/macro"
|
import { useLingui, Trans } from "@lingui/react/macro"
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
@@ -68,7 +68,7 @@ export default function SystemsTable() {
|
|||||||
}
|
}
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [])
|
const columnDefs = useMemo(() => SystemsTableColumns(viewMode), [viewMode])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -291,15 +291,9 @@ const SystemTableRow = memo(
|
|||||||
return (
|
return (
|
||||||
<TableRow
|
<TableRow
|
||||||
// data-state={row.getIsSelected() && "selected"}
|
// data-state={row.getIsSelected() && "selected"}
|
||||||
className={cn("cursor-pointer transition-opacity", {
|
className={cn("cursor-pointer transition-opacity relative", {
|
||||||
"opacity-50": system.status === "paused",
|
"opacity-50": system.status === "paused",
|
||||||
})}
|
})}
|
||||||
onClick={(e) => {
|
|
||||||
const target = e.target as HTMLElement
|
|
||||||
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
|
|
||||||
navigate(getPagePath($router, "system", { name: system.name }))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{row.getVisibleCells().map((cell) => (
|
{row.getVisibleCells().map((cell) => (
|
||||||
<TableCell
|
<TableCell
|
||||||
@@ -307,7 +301,7 @@ const SystemTableRow = memo(
|
|||||||
style={{
|
style={{
|
||||||
width: cell.column.getSize(),
|
width: cell.column.getSize(),
|
||||||
}}
|
}}
|
||||||
className={cn("overflow-hidden relative", length > 10 ? "py-2" : "py-2.5")}
|
className={length > 10 ? "py-2" : "py-2.5"}
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const buttonVariants = cva(
|
|||||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||||
outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
|
outline: "border bg-background hover:bg-accent/70 dark:hover:bg-accent/50 hover:text-accent-foreground",
|
||||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
ghost: "hover:bg-accent/70 hover:text-accent-foreground",
|
||||||
link: "text-primary underline-offset-4 hover:underline",
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
"peer size-4 flex items-center justify-center shrink-0 rounded-[.3em] border border-input ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
|
||||||
|
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const CommandItem = React.forwardRef<
|
|||||||
<CommandPrimitive.Item
|
<CommandPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
|
"relative flex cursor-default opacity-70 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent/70 aria-selected:opacity-90 data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
|
"flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent/70 data-[state=open]:bg-accent/70",
|
||||||
inset && "ps-8",
|
inset && "ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
inset && "ps-8",
|
inset && "ps-8",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
@@ -95,7 +95,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -118,7 +118,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 ps-8 pe-2 text-sm outline-none focus:bg-accent/70 focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 30 8% 98.5%;
|
--background: 30 8% 98%;
|
||||||
--foreground: 30 0% 0%;
|
--foreground: 30 0% 0%;
|
||||||
--card: 30 0% 100%;
|
--card: 30 0% 100%;
|
||||||
--card-foreground: 240 6.67% 2.94%;
|
--card-foreground: 240 6.67% 2.94%;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
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"
|
||||||
|
|
||||||
/** PocketBase JS Client */
|
/** PocketBase JS Client */
|
||||||
export const pb = new PocketBase(basePath)
|
export const pb = new PocketBase(basePath)
|
||||||
@@ -10,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)
|
||||||
@@ -39,12 +40,11 @@ export const $maxValues = atom(false)
|
|||||||
export const $userSettings = map<UserSettings>({
|
export const $userSettings = map<UserSettings>({
|
||||||
chartTime: "1h",
|
chartTime: "1h",
|
||||||
emails: [pb.authStore.record?.email || ""],
|
emails: [pb.authStore.record?.email || ""],
|
||||||
|
unitNet: Unit.Bytes,
|
||||||
|
unitTemp: Unit.Celsius,
|
||||||
})
|
})
|
||||||
// update local storage on change
|
// update chart time on change
|
||||||
$userSettings.subscribe((value) => {
|
$userSettings.subscribe((value) => $chartTime.set(value.chartTime))
|
||||||
// console.log('user settings changed', value)
|
|
||||||
$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>>
|
||||||
|
|||||||
@@ -69,6 +69,9 @@ module.exports = {
|
|||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
transitionDuration: {
|
||||||
|
50: "50ms",
|
||||||
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
md: "calc(var(--radius) - 2px)",
|
md: "calc(var(--radius) - 2px)",
|
||||||
|
|||||||
Reference in New Issue
Block a user