mirror of
https://github.com/henrygd/beszel.git
synced 2026-05-31 21:51:50 +02:00
Compare commits
28 Commits
v0.18.7
...
l10n_main_
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
123d3270d8 | ||
|
|
1073039a97 | ||
|
|
8599e9de94 | ||
|
|
b8b3c8eb4e | ||
|
|
362306006a | ||
|
|
8c1df7cdec | ||
|
|
0d6d493fcd | ||
|
|
b7ffbb1234 | ||
|
|
c28d016472 | ||
|
|
772c053804 | ||
|
|
6648f6bfe9 | ||
|
|
88b2da9fd4 | ||
|
|
e5507fa106 | ||
|
|
a024c3cfd0 | ||
|
|
07466804e7 | ||
|
|
981c788d6f | ||
|
|
f5576759de | ||
|
|
be0b708064 | ||
|
|
ab3a3de46c | ||
|
|
1556e53926 | ||
|
|
e3ade3aeb8 | ||
|
|
b013f06956 | ||
|
|
3793b27958 | ||
|
|
5b02158228 | ||
|
|
0ae8c42ae0 | ||
|
|
ea80f3c5a2 | ||
|
|
c3dffff5e4 | ||
|
|
06fdd0e7a8 |
@@ -82,6 +82,9 @@ func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
|||||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||||
}
|
}
|
||||||
paths, err := getBatteryPaths()
|
paths, err := getBatteryPaths()
|
||||||
|
if err != nil {
|
||||||
|
return batteryPercent, batteryState, err
|
||||||
|
}
|
||||||
if len(paths) == 0 {
|
if len(paths) == 0 {
|
||||||
return batteryPercent, batteryState, errors.New("no batteries")
|
return batteryPercent, batteryState, errors.New("no batteries")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import (
|
|||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -104,6 +105,11 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
}
|
}
|
||||||
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
||||||
|
|
||||||
|
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
|
||||||
|
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
|
||||||
|
os.Setenv("ALL_PROXY", val)
|
||||||
|
}
|
||||||
|
|
||||||
client.options = &gws.ClientOption{
|
client.options = &gws.ClientOption{
|
||||||
Addr: client.hubURL.String(),
|
Addr: client.hubURL.String(),
|
||||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
@@ -112,6 +118,9 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
"X-Token": []string{client.token},
|
"X-Token": []string{client.token},
|
||||||
"X-Beszel": []string{beszel.Version},
|
"X-Beszel": []string{beszel.Version},
|
||||||
},
|
},
|
||||||
|
NewDialer: func() (gws.Dialer, error) {
|
||||||
|
return proxy.FromEnvironment(), nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return client.options
|
return client.options
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
|||||||
func readSysfsFloat(path string) (float64, error) {
|
func readSysfsFloat(path string) (float64, error) {
|
||||||
val, err := utils.ReadStringFileLimited(path, 64)
|
val, err := utils.ReadStringFileLimited(path, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return strconv.ParseFloat(val, 64)
|
return strconv.ParseFloat(val, 64)
|
||||||
|
|||||||
@@ -1118,6 +1118,9 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
|||||||
smartData.SerialNumber = data.SerialNumber
|
smartData.SerialNumber = data.SerialNumber
|
||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
|
if smartData.Capacity == 0 {
|
||||||
|
smartData.Capacity = data.NVMeTotalCapacity
|
||||||
|
}
|
||||||
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
||||||
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
// Package utils provides utility functions for the agent.
|
||||||
package utils
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
@@ -68,6 +70,9 @@ func ReadStringFileLimited(path string, maxSize int) (string, error) {
|
|||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
|
||||||
|
}
|
||||||
return strings.TrimSpace(string(buf[:n])), nil
|
return strings.TrimSpace(string(buf[:n])), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2
go.mod
2
go.mod
@@ -10,7 +10,7 @@ require (
|
|||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.9.1
|
github.com/lxzan/gws v1.9.1
|
||||||
github.com/nicholas-fedor/shoutrrr v0.14.1
|
github.com/nicholas-fedor/shoutrrr v0.14.3
|
||||||
github.com/pocketbase/dbx v1.12.0
|
github.com/pocketbase/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.36.8
|
github.com/pocketbase/pocketbase v0.36.8
|
||||||
github.com/shirou/gopsutil/v4 v4.26.3
|
github.com/shirou/gopsutil/v4 v4.26.3
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -85,8 +85,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
|||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.14.1 h1:6sx4cJNfNuUtD6ygGlB0dqcCQ+abfsUh+b+6jgujf6A=
|
github.com/nicholas-fedor/shoutrrr v0.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.14.1/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
||||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||||
|
|||||||
@@ -302,21 +302,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
|
||||||
var data struct {
|
|
||||||
URL string `json:"url"`
|
|
||||||
}
|
|
||||||
err := e.BindBody(&data)
|
|
||||||
if err != nil || data.URL == "" {
|
|
||||||
return e.BadRequestError("URL is required", err)
|
|
||||||
}
|
|
||||||
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
|
||||||
if err != nil {
|
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
|
||||||
}
|
|
||||||
return e.JSON(200, map[string]bool{"err": false})
|
|
||||||
}
|
|
||||||
|
|
||||||
// setAlertTriggered updates the "triggered" status of an alert record in the database
|
// setAlertTriggered updates the "triggered" status of an alert record in the database
|
||||||
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
|
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||||
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
|
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package alerts
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -117,3 +121,72 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
|
||||||
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
|
var data struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
err := e.BindBody(&data)
|
||||||
|
if err != nil || data.URL == "" {
|
||||||
|
return e.BadRequestError("URL is required", err)
|
||||||
|
}
|
||||||
|
// Only allow admins to send test notifications to internal URLs
|
||||||
|
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
||||||
|
internalURL, err := isInternalURL(data.URL)
|
||||||
|
if err != nil {
|
||||||
|
return e.BadRequestError(err.Error(), nil)
|
||||||
|
}
|
||||||
|
if internalURL {
|
||||||
|
return e.ForbiddenError("Only admins can send to internal destinations", nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||||
|
if err != nil {
|
||||||
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
|
}
|
||||||
|
return e.JSON(200, map[string]bool{"err": false})
|
||||||
|
}
|
||||||
|
|
||||||
|
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
|
||||||
|
func isInternalURL(rawURL string) (bool, error) {
|
||||||
|
parsedURL, err := url.Parse(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
host := parsedURL.Hostname()
|
||||||
|
if host == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.EqualFold(host, "localhost") {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ip := net.ParseIP(host); ip != nil {
|
||||||
|
return isInternalIP(ip), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some Shoutrrr URLs use the host position for service identifiers rather than a
|
||||||
|
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
|
||||||
|
// to names that look like actual hostnames so valid service URLs keep working.
|
||||||
|
if !strings.Contains(host, ".") {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, err := net.LookupIP(host)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.ContainsFunc(ips, isInternalIP) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isInternalIP(ip net.IP) bool {
|
||||||
|
return ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified()
|
||||||
|
}
|
||||||
|
|||||||
501
internal/alerts/alerts_api_test.go
Normal file
501
internal/alerts/alerts_api_test.go
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package alerts_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
|
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||||
|
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
|
||||||
|
"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 TestIsInternalURL(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
url string
|
||||||
|
internal bool
|
||||||
|
}{
|
||||||
|
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
|
||||||
|
{name: "localhost hostname", url: "generic://localhost", internal: true},
|
||||||
|
{name: "localhost hostname", url: "generic+http://localhost/api/v1/postStuff", internal: true},
|
||||||
|
{name: "localhost hostname", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
|
||||||
|
{name: "localhost hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
|
||||||
|
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
|
||||||
|
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
|
||||||
|
{name: "single label service url", url: "slack://token@team/channel", internal: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
internal, err := alerts.IsInternalURL(testCase.url)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, testCase.internal, internal)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestSendTestNotification(t *testing.T) {
|
||||||
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
userToken, err := user.NewAuthToken()
|
||||||
|
|
||||||
|
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||||
|
assert.NoError(t, err, "Failed to create admin user")
|
||||||
|
adminUserToken, err := adminUser.NewAuthToken()
|
||||||
|
|
||||||
|
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
||||||
|
assert.NoError(t, err, "Failed to create superuser")
|
||||||
|
superuserToken, err := superuser.NewAuthToken()
|
||||||
|
assert.NoError(t, err, "Failed to create superuser auth token")
|
||||||
|
|
||||||
|
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||||
|
return hub.TestApp
|
||||||
|
}
|
||||||
|
|
||||||
|
scenarios := []beszelTests.ApiScenario{
|
||||||
|
{
|
||||||
|
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 external 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://8.8.8.8",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"err\":"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - local url with user auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://localhost:8010",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Only admins"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - internal url with user auth should fail",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": userToken,
|
||||||
|
},
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic+http://192.168.0.5",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 403,
|
||||||
|
ExpectedContent: []string{"Only admins"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - internal url with admin auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": adminUserToken,
|
||||||
|
},
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"err\":"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: "/api/beszel/test-notification",
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": superuserToken,
|
||||||
|
},
|
||||||
|
Body: jsonReader(map[string]any{
|
||||||
|
"url": "generic://127.0.0.1",
|
||||||
|
}),
|
||||||
|
ExpectedStatus: 200,
|
||||||
|
ExpectedContent: []string{"\"err\":"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, scenario := range scenarios {
|
||||||
|
scenario.Test(t)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,11 +3,6 @@
|
|||||||
package alerts_test
|
package alerts_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,359 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAlertsHistory(t *testing.T) {
|
func TestAlertsHistory(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
|||||||
@@ -95,3 +95,7 @@ func (am *AlertManager) RestorePendingStatusAlerts() error {
|
|||||||
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
|
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||||
return am.setAlertTriggered(alert, triggered)
|
return am.setAlertTriggered(alert, triggered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsInternalURL(rawURL string) (bool, error) {
|
||||||
|
return isInternalURL(rawURL)
|
||||||
|
}
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
|
|||||||
FirmwareVersion string `json:"firmware_version"`
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
||||||
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
|
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
|
||||||
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
||||||
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
|
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
|
||||||
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
||||||
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/ghupdate"
|
"github.com/henrygd/beszel/internal/ghupdate"
|
||||||
"github.com/henrygd/beszel/internal/hub/config"
|
"github.com/henrygd/beszel/internal/hub/config"
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -70,13 +71,13 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
|||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
// authenticate with trusted header
|
// authenticate with trusted header
|
||||||
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
if autoLogin, _ := utils.GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
return authorizeRequestWithEmail(e, autoLogin)
|
return authorizeRequestWithEmail(e, autoLogin)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// authenticate with trusted header
|
// authenticate with trusted header
|
||||||
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
||||||
})
|
})
|
||||||
@@ -104,7 +105,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
apiAuth.GET("/info", h.getInfo)
|
apiAuth.GET("/info", h.getInfo)
|
||||||
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
|
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
|
||||||
// check for updates
|
// check for updates
|
||||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||||
var updateInfo UpdateInfo
|
var updateInfo UpdateInfo
|
||||||
apiAuth.GET("/update", updateInfo.getUpdate)
|
apiAuth.GET("/update", updateInfo.getUpdate)
|
||||||
}
|
}
|
||||||
@@ -127,7 +128,7 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// /containers routes
|
// /containers routes
|
||||||
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||||
// get container logs
|
// get container logs
|
||||||
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
||||||
// get container info
|
// get container info
|
||||||
@@ -147,7 +148,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error {
|
|||||||
Key: h.pubKey,
|
Key: h.pubKey,
|
||||||
Version: beszel.Version,
|
Version: beszel.Version,
|
||||||
}
|
}
|
||||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||||
info.CheckUpdate = true
|
info.CheckUpdate = true
|
||||||
}
|
}
|
||||||
return e.JSON(http.StatusOK, info)
|
return e.JSON(http.StatusOK, info)
|
||||||
@@ -315,7 +316,7 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
|
|||||||
}
|
}
|
||||||
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||||
return e.NotFoundError("", nil)
|
return e.NotFoundError("", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,7 +351,7 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
|||||||
return e.BadRequestError("Invalid system or service parameter", nil)
|
return e.BadRequestError("Invalid system or service parameter", nil)
|
||||||
}
|
}
|
||||||
system, err := h.sm.GetSystem(systemID)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||||
return e.NotFoundError("", nil)
|
return e.NotFoundError("", nil)
|
||||||
}
|
}
|
||||||
// verify service exists before fetching details
|
// verify service exists before fetching details
|
||||||
@@ -378,7 +379,7 @@ func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
|
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||||
return e.NotFoundError("", nil)
|
return e.NotFoundError("", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -66,31 +66,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
scenarios := []beszelTests.ApiScenario{
|
||||||
// Auth Protected Routes - Should require authentication
|
// 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",
|
Name: "GET /config-yaml - no auth should fail",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -369,6 +344,23 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": user2Token,
|
"Authorization": user2Token,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Name: "GET /containers/info - SHARE_ALL_SYSTEMS allows non-member user",
|
||||||
|
Method: http.MethodGet,
|
||||||
|
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
|
||||||
|
ExpectedStatus: 500,
|
||||||
|
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||||
|
TestAppFactory: testAppFactory,
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Authorization": user2Token,
|
||||||
|
},
|
||||||
|
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||||
|
t.Setenv("SHARE_ALL_SYSTEMS", "true")
|
||||||
|
},
|
||||||
|
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||||
|
t.Setenv("SHARE_ALL_SYSTEMS", "")
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
Name: "GET /containers/logs - with auth but missing system param should fail",
|
Name: "GET /containers/logs - with auth but missing system param should fail",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import "github.com/pocketbase/pocketbase/core"
|
import (
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
type collectionRules struct {
|
type collectionRules struct {
|
||||||
list *string
|
list *string
|
||||||
@@ -22,11 +25,11 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||||
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
disablePasswordAuth, _ := utils.GetEnv("DISABLE_PASSWORD_AUTH")
|
||||||
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
||||||
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||||
// allow oauth user creation if USER_CREATION is set
|
// allow oauth user creation if USER_CREATION is set
|
||||||
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
if userCreation, _ := utils.GetEnv("USER_CREATION"); userCreation == "true" {
|
||||||
cr := "@request.context = 'oauth2'"
|
cr := "@request.context = 'oauth2'"
|
||||||
usersCollection.CreateRule = &cr
|
usersCollection.CreateRule = &cr
|
||||||
} else {
|
} else {
|
||||||
@@ -34,7 +37,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// enable mfaOtp mfa if MFA_OTP env var is set
|
// enable mfaOtp mfa if MFA_OTP env var is set
|
||||||
mfaOtp, _ := GetEnv("MFA_OTP")
|
mfaOtp, _ := utils.GetEnv("MFA_OTP")
|
||||||
usersCollection.OTP.Length = 6
|
usersCollection.OTP.Length = 6
|
||||||
superusersCollection.OTP.Length = 6
|
superusersCollection.OTP.Length = 6
|
||||||
usersCollection.OTP.Enabled = mfaOtp == "true"
|
usersCollection.OTP.Enabled = mfaOtp == "true"
|
||||||
@@ -50,7 +53,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
|
|
||||||
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
|
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
|
||||||
// system-scoped data. Write rules continue to block readonly users.
|
// system-scoped data. Write rules continue to block readonly users.
|
||||||
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
shareAllSystems, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
|
||||||
|
|
||||||
authenticatedRule := "@request.auth.id != \"\""
|
authenticatedRule := "@request.auth.id != \"\""
|
||||||
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"
|
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/hub/config"
|
"github.com/henrygd/beszel/internal/hub/config"
|
||||||
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
"github.com/henrygd/beszel/internal/records"
|
"github.com/henrygd/beszel/internal/records"
|
||||||
"github.com/henrygd/beszel/internal/users"
|
"github.com/henrygd/beszel/internal/users"
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ func NewHub(app core.App) *Hub {
|
|||||||
hub.um = users.NewUserManager(hub)
|
hub.um = users.NewUserManager(hub)
|
||||||
hub.rm = records.NewRecordManager(hub)
|
hub.rm = records.NewRecordManager(hub)
|
||||||
hub.sm = systems.NewSystemManager(hub)
|
hub.sm = systems.NewSystemManager(hub)
|
||||||
hub.hb = heartbeat.New(app, GetEnv)
|
hub.hb = heartbeat.New(app, utils.GetEnv)
|
||||||
if hub.hb != nil {
|
if hub.hb != nil {
|
||||||
hub.hbStop = make(chan struct{})
|
hub.hbStop = make(chan struct{})
|
||||||
}
|
}
|
||||||
@@ -52,15 +53,6 @@ func NewHub(app core.App) *Hub {
|
|||||||
return hub
|
return hub
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
|
||||||
func GetEnv(key string) (value string, exists bool) {
|
|
||||||
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
|
||||||
return value, exists
|
|
||||||
}
|
|
||||||
// Fallback to the old unprefixed key
|
|
||||||
return os.LookupEnv(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied.
|
// onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied.
|
||||||
// This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose.
|
// This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose.
|
||||||
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
|
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
|
||||||
@@ -131,7 +123,7 @@ func (h *Hub) initialize(app core.App) error {
|
|||||||
// batch requests (for alerts)
|
// batch requests (for alerts)
|
||||||
settings.Batch.Enabled = true
|
settings.Batch.Enabled = true
|
||||||
// set URL if APP_URL env is set
|
// set URL if APP_URL env is set
|
||||||
if appURL, isSet := GetEnv("APP_URL"); isSet {
|
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
|
||||||
h.appURL = appURL
|
h.appURL = appURL
|
||||||
settings.Meta.AppURL = appURL
|
settings.Meta.AppURL = appURL
|
||||||
}
|
}
|
||||||
|
|||||||
42
internal/hub/server.go
Normal file
42
internal/hub/server.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PublicAppInfo defines the structure of the public app information that will be injected into the HTML
|
||||||
|
type PublicAppInfo struct {
|
||||||
|
BASE_PATH string
|
||||||
|
HUB_VERSION string
|
||||||
|
HUB_URL string
|
||||||
|
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modifyIndexHTML injects the public app information into the index.html content
|
||||||
|
func modifyIndexHTML(hub *Hub, html []byte) string {
|
||||||
|
info := getPublicAppInfo(hub)
|
||||||
|
content, err := json.Marshal(info)
|
||||||
|
if err != nil {
|
||||||
|
return string(html)
|
||||||
|
}
|
||||||
|
htmlContent := strings.ReplaceAll(string(html), "./", info.BASE_PATH)
|
||||||
|
return strings.Replace(htmlContent, "\"{info}\"", string(content), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPublicAppInfo(hub *Hub) PublicAppInfo {
|
||||||
|
parsedURL, _ := url.Parse(hub.appURL)
|
||||||
|
info := PublicAppInfo{
|
||||||
|
BASE_PATH: strings.TrimSuffix(parsedURL.Path, "/") + "/",
|
||||||
|
HUB_VERSION: beszel.Version,
|
||||||
|
HUB_URL: hub.appURL,
|
||||||
|
}
|
||||||
|
if val, _ := utils.GetEnv("OAUTH_DISABLE_POPUP"); val == "true" {
|
||||||
|
info.OAUTH_DISABLE_POPUP = true
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
@@ -10,8 +10,6 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||||
)
|
)
|
||||||
@@ -38,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
// Create a new response with the modified body
|
// Create a new response with the modified body
|
||||||
modifiedBody := rm.modifyHTML(string(body))
|
modifiedBody := modifyIndexHTML(rm.hub, body)
|
||||||
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||||
resp.ContentLength = int64(len(modifiedBody))
|
resp.ContentLength = int64(len(modifiedBody))
|
||||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||||
@@ -46,19 +44,6 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (rm *responseModifier) modifyHTML(html string) string {
|
|
||||||
parsedURL, err := url.Parse(rm.hub.appURL)
|
|
||||||
if err != nil {
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
// fix base paths in html if using subpath
|
|
||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
||||||
html = strings.ReplaceAll(html, "./", basePath)
|
|
||||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
|
||||||
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
|
||||||
return html
|
|
||||||
}
|
|
||||||
|
|
||||||
// startServer sets up the development server for Beszel
|
// startServer sets up the development server for Beszel
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ package hub
|
|||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
"github.com/henrygd/beszel/internal/site"
|
"github.com/henrygd/beszel/internal/site"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
@@ -17,22 +16,13 @@ import (
|
|||||||
|
|
||||||
// startServer sets up the production server for Beszel
|
// startServer sets up the production server for Beszel
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
// parse app url
|
|
||||||
parsedURL, err := url.Parse(h.appURL)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// fix base paths in html if using subpath
|
|
||||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
|
||||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||||
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
html := modifyIndexHTML(h, indexFile)
|
||||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
|
||||||
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
|
||||||
// set up static asset serving
|
// set up static asset serving
|
||||||
staticPaths := [2]string{"/static/", "/assets/"}
|
staticPaths := [2]string{"/static/", "/assets/"}
|
||||||
serveStatic := apis.Static(site.DistDirFS, false)
|
serveStatic := apis.Static(site.DistDirFS, false)
|
||||||
// get CSP configuration
|
// get CSP configuration
|
||||||
csp, cspExists := GetEnv("CSP")
|
csp, cspExists := utils.GetEnv("CSP")
|
||||||
// add route
|
// add route
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
// serve static assets if path is in staticPaths
|
// serve static assets if path is in staticPaths
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"hash/fnv"
|
"hash/fnv"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net"
|
"net"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/hub/transport"
|
"github.com/henrygd/beszel/internal/hub/transport"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
"github.com/henrygd/beszel/internal/hub/ws"
|
"github.com/henrygd/beszel/internal/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
@@ -353,14 +353,25 @@ func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
|||||||
return record, nil
|
return record, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HasUser checks if the given user ID is in the system's users list.
|
// HasUser checks if the given user is in the system's users list.
|
||||||
func (sys *System) HasUser(app core.App, userID string) bool {
|
// Returns true if SHARE_ALL_SYSTEMS is enabled (any authenticated user can access any system).
|
||||||
record, err := sys.getRecord(app)
|
func (sys *System) HasUser(app core.App, user *core.Record) bool {
|
||||||
if err != nil {
|
if user == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
users := record.GetStringSlice("users")
|
if v, _ := utils.GetEnv("SHARE_ALL_SYSTEMS"); v == "true" {
|
||||||
return slices.Contains(users, userID)
|
return true
|
||||||
|
}
|
||||||
|
var recordData = struct {
|
||||||
|
Users string
|
||||||
|
}{}
|
||||||
|
err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
|
||||||
|
Bind(dbx.Params{"id": sys.Id}).
|
||||||
|
One(&recordData)
|
||||||
|
if err != nil || recordData.Users == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.Contains(recordData.Users, user.Id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// setDown marks a system as down in the database.
|
// setDown marks a system as down in the database.
|
||||||
|
|||||||
@@ -421,3 +421,60 @@ func testOld(t *testing.T, hub *tests.TestHub) {
|
|||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHasUser(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
sm := hub.GetSystemManager()
|
||||||
|
err = sm.Initialize()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user1, err := tests.CreateUser(hub, "user1@test.com", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
user2, err := tests.CreateUser(hub, "user2@test.com", "password123")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
systemRecord, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "has-user-test",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": "33914",
|
||||||
|
"users": []string{user1.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sys, err := sm.GetSystemFromStore(systemRecord.Id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("user in list returns true", func(t *testing.T) {
|
||||||
|
assert.True(t, sys.HasUser(hub, user1))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("user not in list returns false", func(t *testing.T) {
|
||||||
|
assert.False(t, sys.HasUser(hub, user2))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown user ID returns false", func(t *testing.T) {
|
||||||
|
assert.False(t, sys.HasUser(hub, nil))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
|
||||||
|
t.Setenv("SHARE_ALL_SYSTEMS", "true")
|
||||||
|
assert.True(t, sys.HasUser(hub, user2))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("BESZEL_HUB_SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
|
||||||
|
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||||
|
assert.True(t, sys.HasUser(hub, user2))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("additional user works", func(t *testing.T) {
|
||||||
|
assert.False(t, sys.HasUser(hub, user2))
|
||||||
|
systemRecord.Set("users", []string{user1.Id, user2.Id})
|
||||||
|
err = hub.Save(systemRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, sys.HasUser(hub, user1))
|
||||||
|
assert.True(t, sys.HasUser(hub, user2))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
12
internal/hub/utils/utils.go
Normal file
12
internal/hub/utils/utils.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Package utils provides utility functions for the hub.
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||||
|
func GetEnv(key string) (value string, exists bool) {
|
||||||
|
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||||
|
return value, exists
|
||||||
|
}
|
||||||
|
return os.LookupEnv(key)
|
||||||
|
}
|
||||||
@@ -3,10 +3,8 @@ package records
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
@@ -39,16 +37,6 @@ type StatsRecord struct {
|
|||||||
Stats []byte `db:"stats"`
|
Stats []byte `db:"stats"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// global variables for reusing allocations
|
|
||||||
var (
|
|
||||||
statsRecord StatsRecord
|
|
||||||
containerStats []container.Stats
|
|
||||||
sumStats system.Stats
|
|
||||||
tempStats system.Stats
|
|
||||||
queryParams = make(dbx.Params, 1)
|
|
||||||
containerSums = make(map[string]*container.Stats)
|
|
||||||
)
|
|
||||||
|
|
||||||
// Create longer records by averaging shorter records
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
@@ -163,41 +151,47 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
statsRecord.Stats = statsRecord.Stats[:0]
|
|
||||||
|
|
||||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate the average stats of a list of system_stats records without reflect
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
|
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
|
||||||
// Clear/reset global structs for reuse
|
stats := make([]system.Stats, 0, len(records))
|
||||||
sumStats = system.Stats{}
|
var row StatsRecord
|
||||||
tempStats = system.Stats{}
|
params := make(dbx.Params, 1)
|
||||||
sum := &sumStats
|
for _, rec := range records {
|
||||||
stats := &tempStats
|
row.Stats = row.Stats[:0]
|
||||||
|
params["id"] = rec.Id
|
||||||
|
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(params).One(&row)
|
||||||
|
var s system.Stats
|
||||||
|
if err := json.Unmarshal(row.Stats, &s); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
stats = append(stats, s)
|
||||||
|
}
|
||||||
|
result := AverageSystemStatsSlice(stats)
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AverageSystemStatsSlice computes the average of a slice of system stats.
|
||||||
|
func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||||
|
var sum system.Stats
|
||||||
|
count := float64(len(records))
|
||||||
|
if count == 0 {
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
// necessary because uint8 is not big enough for the sum
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
// accumulate per-core usage across records
|
// accumulate per-core usage across records
|
||||||
var cpuCoresSums []uint64
|
var cpuCoresSums []uint64
|
||||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||||
var cpuBreakdownSums []float64
|
var cpuBreakdownSums []float64
|
||||||
|
|
||||||
count := float64(len(records))
|
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
|
|
||||||
// Accumulate totals
|
// Accumulate totals
|
||||||
for _, record := range records {
|
for i := range records {
|
||||||
id := record.Id
|
stats := &records[i]
|
||||||
// clear global statsRecord for reuse
|
|
||||||
statsRecord.Stats = statsRecord.Stats[:0]
|
|
||||||
// reset tempStats each iteration to avoid omitzero fields retaining stale values
|
|
||||||
*stats = system.Stats{}
|
|
||||||
|
|
||||||
queryParams["id"] = id
|
|
||||||
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
|
||||||
if err := json.Unmarshal(statsRecord.Stats, stats); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
// accumulate cpu time breakdowns if present
|
// accumulate cpu time breakdowns if present
|
||||||
@@ -205,8 +199,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
||||||
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
||||||
}
|
}
|
||||||
for i, v := range stats.CpuBreakdown {
|
for j, v := range stats.CpuBreakdown {
|
||||||
cpuBreakdownSums[i] += v
|
cpuBreakdownSums[j] += v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
@@ -242,8 +236,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
// extend slices to accommodate core count
|
// extend slices to accommodate core count
|
||||||
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
||||||
}
|
}
|
||||||
for i, v := range stats.CpuCoresUsage {
|
for j, v := range stats.CpuCoresUsage {
|
||||||
cpuCoresSums[i] += uint64(v)
|
cpuCoresSums[j] += uint64(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set peak values
|
// Set peak values
|
||||||
@@ -343,109 +337,107 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute averages in place
|
// Compute averages
|
||||||
if count > 0 {
|
sum.Cpu = twoDecimals(sum.Cpu / count)
|
||||||
sum.Cpu = twoDecimals(sum.Cpu / count)
|
sum.Mem = twoDecimals(sum.Mem / count)
|
||||||
sum.Mem = twoDecimals(sum.Mem / count)
|
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
||||||
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
sum.MemPct = twoDecimals(sum.MemPct / count)
|
||||||
sum.MemPct = twoDecimals(sum.MemPct / count)
|
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
||||||
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
||||||
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
sum.Swap = twoDecimals(sum.Swap / count)
|
||||||
sum.Swap = twoDecimals(sum.Swap / count)
|
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
||||||
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
||||||
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
||||||
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
||||||
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
||||||
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
for i := range sum.DiskIoStats {
|
||||||
for i := range sum.DiskIoStats {
|
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
|
||||||
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
|
}
|
||||||
}
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.Battery[0] = uint8(batterySum / int(count))
|
||||||
sum.Battery[0] = uint8(batterySum / int(count))
|
|
||||||
|
|
||||||
// Average network interfaces
|
// Average network interfaces
|
||||||
if sum.NetworkInterfaces != nil {
|
if sum.NetworkInterfaces != nil {
|
||||||
for key := range sum.NetworkInterfaces {
|
for key := range sum.NetworkInterfaces {
|
||||||
sum.NetworkInterfaces[key] = [4]uint64{
|
sum.NetworkInterfaces[key] = [4]uint64{
|
||||||
sum.NetworkInterfaces[key][0] / uint64(count),
|
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||||
sum.NetworkInterfaces[key][1] / uint64(count),
|
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||||
sum.NetworkInterfaces[key][2],
|
sum.NetworkInterfaces[key][2],
|
||||||
sum.NetworkInterfaces[key][3],
|
sum.NetworkInterfaces[key][3],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average temperatures
|
||||||
|
if sum.Temperatures != nil && tempCount > 0 {
|
||||||
|
for key := range sum.Temperatures {
|
||||||
|
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average extra filesystem stats
|
||||||
|
if sum.ExtraFs != nil {
|
||||||
|
for key := range sum.ExtraFs {
|
||||||
|
fs := sum.ExtraFs[key]
|
||||||
|
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
|
||||||
|
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
||||||
|
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
||||||
|
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
||||||
|
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
||||||
|
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
||||||
|
for i := range fs.DiskIoStats {
|
||||||
|
fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average GPU data
|
||||||
|
if sum.GPUData != nil {
|
||||||
|
for id := range sum.GPUData {
|
||||||
|
gpu := sum.GPUData[id]
|
||||||
|
gpu.Temperature = twoDecimals(gpu.Temperature / count)
|
||||||
|
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
|
||||||
|
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
|
||||||
|
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||||
|
gpu.Power = twoDecimals(gpu.Power / count)
|
||||||
|
gpu.Count = twoDecimals(gpu.Count / count)
|
||||||
|
|
||||||
|
if gpu.Engines != nil {
|
||||||
|
for engineKey := range gpu.Engines {
|
||||||
|
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sum.GPUData[id] = gpu
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Average temperatures
|
// Average per-core usage
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if len(cpuCoresSums) > 0 {
|
||||||
for key := range sum.Temperatures {
|
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
||||||
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
|
for i := range cpuCoresSums {
|
||||||
}
|
v := math.Round(float64(cpuCoresSums[i]) / count)
|
||||||
|
avg[i] = uint8(v)
|
||||||
}
|
}
|
||||||
|
sum.CpuCoresUsage = avg
|
||||||
|
}
|
||||||
|
|
||||||
// Average extra filesystem stats
|
// Average CPU breakdown
|
||||||
if sum.ExtraFs != nil {
|
if len(cpuBreakdownSums) > 0 {
|
||||||
for key := range sum.ExtraFs {
|
avg := make([]float64, len(cpuBreakdownSums))
|
||||||
fs := sum.ExtraFs[key]
|
for i := range cpuBreakdownSums {
|
||||||
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
|
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
||||||
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
|
||||||
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
|
||||||
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
|
||||||
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
|
||||||
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
|
||||||
for i := range fs.DiskIoStats {
|
|
||||||
fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average GPU data
|
|
||||||
if sum.GPUData != nil {
|
|
||||||
for id := range sum.GPUData {
|
|
||||||
gpu := sum.GPUData[id]
|
|
||||||
gpu.Temperature = twoDecimals(gpu.Temperature / count)
|
|
||||||
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
|
|
||||||
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
|
|
||||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
|
||||||
gpu.Power = twoDecimals(gpu.Power / count)
|
|
||||||
gpu.Count = twoDecimals(gpu.Count / count)
|
|
||||||
|
|
||||||
if gpu.Engines != nil {
|
|
||||||
for engineKey := range gpu.Engines {
|
|
||||||
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sum.GPUData[id] = gpu
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average per-core usage
|
|
||||||
if len(cpuCoresSums) > 0 {
|
|
||||||
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
|
||||||
for i := range cpuCoresSums {
|
|
||||||
v := math.Round(float64(cpuCoresSums[i]) / count)
|
|
||||||
avg[i] = uint8(v)
|
|
||||||
}
|
|
||||||
sum.CpuCoresUsage = avg
|
|
||||||
}
|
|
||||||
|
|
||||||
// Average CPU breakdown
|
|
||||||
if len(cpuBreakdownSums) > 0 {
|
|
||||||
avg := make([]float64, len(cpuBreakdownSums))
|
|
||||||
for i := range cpuBreakdownSums {
|
|
||||||
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
|
||||||
}
|
|
||||||
sum.CpuBreakdown = avg
|
|
||||||
}
|
}
|
||||||
|
sum.CpuBreakdown = avg
|
||||||
}
|
}
|
||||||
|
|
||||||
return sum
|
return sum
|
||||||
@@ -453,29 +445,33 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||||
// Clear global map for reuse
|
allStats := make([][]container.Stats, 0, len(records))
|
||||||
for k := range containerSums {
|
var row StatsRecord
|
||||||
delete(containerSums, k)
|
params := make(dbx.Params, 1)
|
||||||
}
|
for _, rec := range records {
|
||||||
sums := containerSums
|
row.Stats = row.Stats[:0]
|
||||||
count := float64(len(records))
|
params["id"] = rec.Id
|
||||||
|
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(params).One(&row)
|
||||||
for i := range records {
|
var cs []container.Stats
|
||||||
id := records[i].Id
|
if err := json.Unmarshal(row.Stats, &cs); err != nil {
|
||||||
// clear global statsRecord for reuse
|
|
||||||
statsRecord.Stats = statsRecord.Stats[:0]
|
|
||||||
// must set to nil (not [:0]) to avoid json.Unmarshal reusing backing array
|
|
||||||
// which causes omitzero fields to inherit stale values from previous iterations
|
|
||||||
containerStats = nil
|
|
||||||
|
|
||||||
queryParams["id"] = id
|
|
||||||
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
|
||||||
|
|
||||||
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
|
|
||||||
return []container.Stats{}
|
return []container.Stats{}
|
||||||
}
|
}
|
||||||
|
allStats = append(allStats, cs)
|
||||||
|
}
|
||||||
|
return AverageContainerStatsSlice(allStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AverageContainerStatsSlice computes the average of container stats across multiple time periods.
|
||||||
|
func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
||||||
|
if len(records) == 0 {
|
||||||
|
return []container.Stats{}
|
||||||
|
}
|
||||||
|
sums := make(map[string]*container.Stats)
|
||||||
|
count := float64(len(records))
|
||||||
|
|
||||||
|
for _, containerStats := range records {
|
||||||
for i := range containerStats {
|
for i := range containerStats {
|
||||||
stat := containerStats[i]
|
stat := &containerStats[i]
|
||||||
if _, ok := sums[stat.Name]; !ok {
|
if _, ok := sums[stat.Name]; !ok {
|
||||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||||
}
|
}
|
||||||
@@ -504,133 +500,6 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete old records
|
|
||||||
func (rm *RecordManager) DeleteOldRecords() {
|
|
||||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
|
||||||
err := deleteOldSystemStats(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = deleteOldContainerRecords(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = deleteOldSystemdServiceRecords(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
err = deleteOldQuietHours(txApp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete old alerts history records
|
|
||||||
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
|
||||||
db := app.DB()
|
|
||||||
var users []struct {
|
|
||||||
Id string `db:"user"`
|
|
||||||
}
|
|
||||||
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for _, user := range users {
|
|
||||||
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes system_stats records older than what is displayed in the UI
|
|
||||||
func deleteOldSystemStats(app core.App) error {
|
|
||||||
// Collections to process
|
|
||||||
collections := [2]string{"system_stats", "container_stats"}
|
|
||||||
|
|
||||||
// Record types and their retention periods
|
|
||||||
type RecordDeletionData struct {
|
|
||||||
recordType string
|
|
||||||
retention time.Duration
|
|
||||||
}
|
|
||||||
recordData := []RecordDeletionData{
|
|
||||||
{recordType: "1m", retention: time.Hour}, // 1 hour
|
|
||||||
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
|
|
||||||
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
|
|
||||||
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
|
|
||||||
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
for _, collection := range collections {
|
|
||||||
// Build the WHERE clause
|
|
||||||
var conditionParts []string
|
|
||||||
var params dbx.Params = make(map[string]any)
|
|
||||||
for i := range recordData {
|
|
||||||
rd := recordData[i]
|
|
||||||
// Create parameterized condition for this record type
|
|
||||||
dateParam := fmt.Sprintf("date%d", i)
|
|
||||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
|
||||||
params[dateParam] = now.Add(-rd.retention)
|
|
||||||
}
|
|
||||||
// Combine conditions with OR
|
|
||||||
conditionStr := strings.Join(conditionParts, " OR ")
|
|
||||||
// Construct and execute the full raw query
|
|
||||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
|
||||||
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
|
||||||
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
|
||||||
func deleteOldSystemdServiceRecords(app core.App) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
|
||||||
|
|
||||||
// Delete systemd service records where updated < twentyMinutesAgo
|
|
||||||
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes container records that haven't been updated in the last 10 minutes
|
|
||||||
func deleteOldContainerRecords(app core.App) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
|
||||||
|
|
||||||
// Delete container records where updated < tenMinutesAgo
|
|
||||||
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to delete old container records: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deletes old quiet hours records where end date has passed
|
|
||||||
func deleteOldQuietHours(app core.App) error {
|
|
||||||
now := time.Now().UTC()
|
|
||||||
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
820
internal/records/records_averaging_test.go
Normal file
820
internal/records/records_averaging_test.go
Normal file
@@ -0,0 +1,820 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package records_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/henrygd/beszel/internal/records"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_Empty(t *testing.T) {
|
||||||
|
result := records.AverageSystemStatsSlice(nil)
|
||||||
|
assert.Equal(t, system.Stats{}, result)
|
||||||
|
|
||||||
|
result = records.AverageSystemStatsSlice([]system.Stats{})
|
||||||
|
assert.Equal(t, system.Stats{}, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_SingleRecord(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 45.67,
|
||||||
|
Mem: 16.0,
|
||||||
|
MemUsed: 8.5,
|
||||||
|
MemPct: 53.12,
|
||||||
|
MemBuffCache: 2.0,
|
||||||
|
Swap: 4.0,
|
||||||
|
SwapUsed: 1.0,
|
||||||
|
DiskTotal: 500.0,
|
||||||
|
DiskUsed: 250.0,
|
||||||
|
DiskPct: 50.0,
|
||||||
|
DiskReadPs: 100.5,
|
||||||
|
DiskWritePs: 200.75,
|
||||||
|
NetworkSent: 10.5,
|
||||||
|
NetworkRecv: 20.25,
|
||||||
|
LoadAvg: [3]float64{1.5, 2.0, 3.5},
|
||||||
|
Bandwidth: [2]uint64{1000, 2000},
|
||||||
|
DiskIO: [2]uint64{500, 600},
|
||||||
|
Battery: [2]uint8{80, 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, 45.67, result.Cpu)
|
||||||
|
assert.Equal(t, 16.0, result.Mem)
|
||||||
|
assert.Equal(t, 8.5, result.MemUsed)
|
||||||
|
assert.Equal(t, 53.12, result.MemPct)
|
||||||
|
assert.Equal(t, 2.0, result.MemBuffCache)
|
||||||
|
assert.Equal(t, 4.0, result.Swap)
|
||||||
|
assert.Equal(t, 1.0, result.SwapUsed)
|
||||||
|
assert.Equal(t, 500.0, result.DiskTotal)
|
||||||
|
assert.Equal(t, 250.0, result.DiskUsed)
|
||||||
|
assert.Equal(t, 50.0, result.DiskPct)
|
||||||
|
assert.Equal(t, 100.5, result.DiskReadPs)
|
||||||
|
assert.Equal(t, 200.75, result.DiskWritePs)
|
||||||
|
assert.Equal(t, 10.5, result.NetworkSent)
|
||||||
|
assert.Equal(t, 20.25, result.NetworkRecv)
|
||||||
|
assert.Equal(t, [3]float64{1.5, 2.0, 3.5}, result.LoadAvg)
|
||||||
|
assert.Equal(t, [2]uint64{1000, 2000}, result.Bandwidth)
|
||||||
|
assert.Equal(t, [2]uint64{500, 600}, result.DiskIO)
|
||||||
|
assert.Equal(t, uint8(80), result.Battery[0])
|
||||||
|
assert.Equal(t, uint8(1), result.Battery[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_BasicAveraging(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
Mem: 16.0,
|
||||||
|
MemUsed: 6.0,
|
||||||
|
MemPct: 37.5,
|
||||||
|
MemBuffCache: 1.0,
|
||||||
|
MemZfsArc: 0.5,
|
||||||
|
Swap: 4.0,
|
||||||
|
SwapUsed: 1.0,
|
||||||
|
DiskTotal: 500.0,
|
||||||
|
DiskUsed: 200.0,
|
||||||
|
DiskPct: 40.0,
|
||||||
|
DiskReadPs: 100.0,
|
||||||
|
DiskWritePs: 200.0,
|
||||||
|
NetworkSent: 10.0,
|
||||||
|
NetworkRecv: 20.0,
|
||||||
|
LoadAvg: [3]float64{1.0, 2.0, 3.0},
|
||||||
|
Bandwidth: [2]uint64{1000, 2000},
|
||||||
|
DiskIO: [2]uint64{400, 600},
|
||||||
|
Battery: [2]uint8{80, 1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 40.0,
|
||||||
|
Mem: 16.0,
|
||||||
|
MemUsed: 10.0,
|
||||||
|
MemPct: 62.5,
|
||||||
|
MemBuffCache: 3.0,
|
||||||
|
MemZfsArc: 1.5,
|
||||||
|
Swap: 4.0,
|
||||||
|
SwapUsed: 3.0,
|
||||||
|
DiskTotal: 500.0,
|
||||||
|
DiskUsed: 300.0,
|
||||||
|
DiskPct: 60.0,
|
||||||
|
DiskReadPs: 200.0,
|
||||||
|
DiskWritePs: 400.0,
|
||||||
|
NetworkSent: 30.0,
|
||||||
|
NetworkRecv: 40.0,
|
||||||
|
LoadAvg: [3]float64{3.0, 4.0, 5.0},
|
||||||
|
Bandwidth: [2]uint64{3000, 4000},
|
||||||
|
DiskIO: [2]uint64{600, 800},
|
||||||
|
Battery: [2]uint8{60, 1},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, 30.0, result.Cpu)
|
||||||
|
assert.Equal(t, 16.0, result.Mem)
|
||||||
|
assert.Equal(t, 8.0, result.MemUsed)
|
||||||
|
assert.Equal(t, 50.0, result.MemPct)
|
||||||
|
assert.Equal(t, 2.0, result.MemBuffCache)
|
||||||
|
assert.Equal(t, 1.0, result.MemZfsArc)
|
||||||
|
assert.Equal(t, 4.0, result.Swap)
|
||||||
|
assert.Equal(t, 2.0, result.SwapUsed)
|
||||||
|
assert.Equal(t, 500.0, result.DiskTotal)
|
||||||
|
assert.Equal(t, 250.0, result.DiskUsed)
|
||||||
|
assert.Equal(t, 50.0, result.DiskPct)
|
||||||
|
assert.Equal(t, 150.0, result.DiskReadPs)
|
||||||
|
assert.Equal(t, 300.0, result.DiskWritePs)
|
||||||
|
assert.Equal(t, 20.0, result.NetworkSent)
|
||||||
|
assert.Equal(t, 30.0, result.NetworkRecv)
|
||||||
|
assert.Equal(t, [3]float64{2.0, 3.0, 4.0}, result.LoadAvg)
|
||||||
|
assert.Equal(t, [2]uint64{2000, 3000}, result.Bandwidth)
|
||||||
|
assert.Equal(t, [2]uint64{500, 700}, result.DiskIO)
|
||||||
|
assert.Equal(t, uint8(70), result.Battery[0])
|
||||||
|
assert.Equal(t, uint8(1), result.Battery[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_PeakValues(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
MaxCpu: 25.0,
|
||||||
|
MemUsed: 6.0,
|
||||||
|
MaxMem: 7.0,
|
||||||
|
NetworkSent: 10.0,
|
||||||
|
MaxNetworkSent: 15.0,
|
||||||
|
NetworkRecv: 20.0,
|
||||||
|
MaxNetworkRecv: 25.0,
|
||||||
|
DiskReadPs: 100.0,
|
||||||
|
MaxDiskReadPs: 120.0,
|
||||||
|
DiskWritePs: 200.0,
|
||||||
|
MaxDiskWritePs: 220.0,
|
||||||
|
Bandwidth: [2]uint64{1000, 2000},
|
||||||
|
MaxBandwidth: [2]uint64{1500, 2500},
|
||||||
|
DiskIO: [2]uint64{400, 600},
|
||||||
|
MaxDiskIO: [2]uint64{500, 700},
|
||||||
|
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||||
|
MaxDiskIoStats: [6]float64{15.0, 25.0, 35.0, 6.0, 9.0, 14.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 40.0,
|
||||||
|
MaxCpu: 50.0,
|
||||||
|
MemUsed: 10.0,
|
||||||
|
MaxMem: 12.0,
|
||||||
|
NetworkSent: 30.0,
|
||||||
|
MaxNetworkSent: 35.0,
|
||||||
|
NetworkRecv: 40.0,
|
||||||
|
MaxNetworkRecv: 45.0,
|
||||||
|
DiskReadPs: 200.0,
|
||||||
|
MaxDiskReadPs: 210.0,
|
||||||
|
DiskWritePs: 400.0,
|
||||||
|
MaxDiskWritePs: 410.0,
|
||||||
|
Bandwidth: [2]uint64{3000, 4000},
|
||||||
|
MaxBandwidth: [2]uint64{3500, 4500},
|
||||||
|
DiskIO: [2]uint64{600, 800},
|
||||||
|
MaxDiskIO: [2]uint64{650, 850},
|
||||||
|
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
|
||||||
|
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, 50.0, result.MaxCpu)
|
||||||
|
assert.Equal(t, 12.0, result.MaxMem)
|
||||||
|
assert.Equal(t, 35.0, result.MaxNetworkSent)
|
||||||
|
assert.Equal(t, 45.0, result.MaxNetworkRecv)
|
||||||
|
assert.Equal(t, 210.0, result.MaxDiskReadPs)
|
||||||
|
assert.Equal(t, 410.0, result.MaxDiskWritePs)
|
||||||
|
assert.Equal(t, [2]uint64{3500, 4500}, result.MaxBandwidth)
|
||||||
|
assert.Equal(t, [2]uint64{650, 850}, result.MaxDiskIO)
|
||||||
|
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, result.DiskIoStats)
|
||||||
|
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, result.MaxDiskIoStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_DiskIoStats(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||||
|
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
DiskIoStats: [6]float64{30.0, 40.0, 50.0, 15.0, 18.0, 22.0},
|
||||||
|
MaxDiskIoStats: [6]float64{28.0, 38.0, 48.0, 14.0, 17.0, 21.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 30.0,
|
||||||
|
DiskIoStats: [6]float64{20.0, 30.0, 40.0, 10.0, 12.0, 16.0},
|
||||||
|
MaxDiskIoStats: [6]float64{25.0, 35.0, 45.0, 11.0, 13.0, 17.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
// Average: (10+30+20)/3=20, (20+40+30)/3=30, (30+50+40)/3=40, (5+15+10)/3=10, (8+18+12)/3≈12.67, (12+22+16)/3≈16.67
|
||||||
|
assert.Equal(t, 20.0, result.DiskIoStats[0])
|
||||||
|
assert.Equal(t, 30.0, result.DiskIoStats[1])
|
||||||
|
assert.Equal(t, 40.0, result.DiskIoStats[2])
|
||||||
|
assert.Equal(t, 10.0, result.DiskIoStats[3])
|
||||||
|
assert.Equal(t, 12.67, result.DiskIoStats[4])
|
||||||
|
assert.Equal(t, 16.67, result.DiskIoStats[5])
|
||||||
|
// Max: current DiskIoStats[0] wins for record 2 (30 > MaxDiskIoStats 28)
|
||||||
|
assert.Equal(t, 30.0, result.MaxDiskIoStats[0])
|
||||||
|
assert.Equal(t, 40.0, result.MaxDiskIoStats[1])
|
||||||
|
assert.Equal(t, 50.0, result.MaxDiskIoStats[2])
|
||||||
|
assert.Equal(t, 15.0, result.MaxDiskIoStats[3])
|
||||||
|
assert.Equal(t, 18.0, result.MaxDiskIoStats[4])
|
||||||
|
assert.Equal(t, 22.0, result.MaxDiskIoStats[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that current DiskIoStats values are considered when computing MaxDiskIoStats.
|
||||||
|
func TestAverageSystemStatsSlice_DiskIoStatsPeakFromCurrentValues(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Cpu: 10.0, DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0}},
|
||||||
|
{Cpu: 20.0, DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0}, MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
// Current value from first record (95, 90, 85, 50, 60, 80) beats MaxDiskIoStats in both records
|
||||||
|
assert.Equal(t, 95.0, result.MaxDiskIoStats[0])
|
||||||
|
assert.Equal(t, 90.0, result.MaxDiskIoStats[1])
|
||||||
|
assert.Equal(t, 85.0, result.MaxDiskIoStats[2])
|
||||||
|
assert.Equal(t, 50.0, result.MaxDiskIoStats[3])
|
||||||
|
assert.Equal(t, 60.0, result.MaxDiskIoStats[4])
|
||||||
|
assert.Equal(t, 80.0, result.MaxDiskIoStats[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that current values are considered when computing peaks
|
||||||
|
// (i.e., current cpu > MaxCpu should still win).
|
||||||
|
func TestAverageSystemStatsSlice_PeakFromCurrentValues(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Cpu: 95.0, MaxCpu: 80.0, MemUsed: 15.0, MaxMem: 10.0},
|
||||||
|
{Cpu: 10.0, MaxCpu: 20.0, MemUsed: 5.0, MaxMem: 8.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, 95.0, result.MaxCpu)
|
||||||
|
assert.Equal(t, 15.0, result.MaxMem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that records without temperature data are excluded from the temperature average.
|
||||||
|
func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
Temperatures: map[string]float64{"cpu": 60.0, "gpu": 70.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
Temperatures: map[string]float64{"cpu": 80.0, "gpu": 90.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// No temperatures - should not affect temp averaging
|
||||||
|
Cpu: 30.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.Temperatures)
|
||||||
|
// Average over 2 records that had temps, not 3
|
||||||
|
assert.Equal(t, 70.0, result.Temperatures["cpu"])
|
||||||
|
assert.Equal(t, 80.0, result.Temperatures["gpu"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
NetworkInterfaces: map[string][4]uint64{
|
||||||
|
"eth0": {100, 200, 150, 250},
|
||||||
|
"eth1": {50, 60, 70, 80},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
NetworkInterfaces: map[string][4]uint64{
|
||||||
|
"eth0": {200, 400, 300, 500},
|
||||||
|
"eth1": {150, 160, 170, 180},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.NetworkInterfaces)
|
||||||
|
// [0] and [1] are averaged, [2] and [3] are max
|
||||||
|
assert.Equal(t, [4]uint64{150, 300, 300, 500}, result.NetworkInterfaces["eth0"])
|
||||||
|
assert.Equal(t, [4]uint64{100, 110, 170, 180}, result.NetworkInterfaces["eth1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_ExtraFs(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
ExtraFs: map[string]*system.FsStats{
|
||||||
|
"/data": {
|
||||||
|
DiskTotal: 1000.0,
|
||||||
|
DiskUsed: 400.0,
|
||||||
|
DiskReadPs: 50.0,
|
||||||
|
DiskWritePs: 100.0,
|
||||||
|
MaxDiskReadPS: 60.0,
|
||||||
|
MaxDiskWritePS: 110.0,
|
||||||
|
DiskReadBytes: 5000,
|
||||||
|
DiskWriteBytes: 10000,
|
||||||
|
MaxDiskReadBytes: 6000,
|
||||||
|
MaxDiskWriteBytes: 11000,
|
||||||
|
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||||
|
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
ExtraFs: map[string]*system.FsStats{
|
||||||
|
"/data": {
|
||||||
|
DiskTotal: 1000.0,
|
||||||
|
DiskUsed: 600.0,
|
||||||
|
DiskReadPs: 150.0,
|
||||||
|
DiskWritePs: 200.0,
|
||||||
|
MaxDiskReadPS: 160.0,
|
||||||
|
MaxDiskWritePS: 210.0,
|
||||||
|
DiskReadBytes: 15000,
|
||||||
|
DiskWriteBytes: 20000,
|
||||||
|
MaxDiskReadBytes: 16000,
|
||||||
|
MaxDiskWriteBytes: 21000,
|
||||||
|
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
|
||||||
|
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.ExtraFs)
|
||||||
|
require.NotNil(t, result.ExtraFs["/data"])
|
||||||
|
fs := result.ExtraFs["/data"]
|
||||||
|
assert.Equal(t, 1000.0, fs.DiskTotal)
|
||||||
|
assert.Equal(t, 500.0, fs.DiskUsed)
|
||||||
|
assert.Equal(t, 100.0, fs.DiskReadPs)
|
||||||
|
assert.Equal(t, 150.0, fs.DiskWritePs)
|
||||||
|
assert.Equal(t, 160.0, fs.MaxDiskReadPS)
|
||||||
|
assert.Equal(t, 210.0, fs.MaxDiskWritePS)
|
||||||
|
assert.Equal(t, uint64(10000), fs.DiskReadBytes)
|
||||||
|
assert.Equal(t, uint64(15000), fs.DiskWriteBytes)
|
||||||
|
assert.Equal(t, uint64(16000), fs.MaxDiskReadBytes)
|
||||||
|
assert.Equal(t, uint64(21000), fs.MaxDiskWriteBytes)
|
||||||
|
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, fs.DiskIoStats)
|
||||||
|
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, fs.MaxDiskIoStats)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that ExtraFs DiskIoStats peak considers current values, not just previous peaks.
|
||||||
|
func TestAverageSystemStatsSlice_ExtraFsDiskIoStatsPeakFromCurrentValues(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
ExtraFs: map[string]*system.FsStats{
|
||||||
|
"/data": {
|
||||||
|
DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, // exceeds MaxDiskIoStats
|
||||||
|
MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
ExtraFs: map[string]*system.FsStats{
|
||||||
|
"/data": {
|
||||||
|
DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0},
|
||||||
|
MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
fs := result.ExtraFs["/data"]
|
||||||
|
assert.Equal(t, 95.0, fs.MaxDiskIoStats[0])
|
||||||
|
assert.Equal(t, 90.0, fs.MaxDiskIoStats[1])
|
||||||
|
assert.Equal(t, 85.0, fs.MaxDiskIoStats[2])
|
||||||
|
assert.Equal(t, 50.0, fs.MaxDiskIoStats[3])
|
||||||
|
assert.Equal(t, 60.0, fs.MaxDiskIoStats[4])
|
||||||
|
assert.Equal(t, 80.0, fs.MaxDiskIoStats[5])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that extra FS peak values consider current values, not just previous peaks.
|
||||||
|
func TestAverageSystemStatsSlice_ExtraFsPeaksFromCurrentValues(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
ExtraFs: map[string]*system.FsStats{
|
||||||
|
"/data": {
|
||||||
|
DiskReadPs: 500.0, // exceeds MaxDiskReadPS
|
||||||
|
MaxDiskReadPS: 100.0,
|
||||||
|
DiskReadBytes: 50000,
|
||||||
|
MaxDiskReadBytes: 10000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
ExtraFs: map[string]*system.FsStats{
|
||||||
|
"/data": {
|
||||||
|
DiskReadPs: 50.0,
|
||||||
|
MaxDiskReadPS: 200.0,
|
||||||
|
DiskReadBytes: 5000,
|
||||||
|
MaxDiskReadBytes: 20000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
fs := result.ExtraFs["/data"]
|
||||||
|
assert.Equal(t, 500.0, fs.MaxDiskReadPS)
|
||||||
|
assert.Equal(t, uint64(50000), fs.MaxDiskReadBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_GPUData(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
GPUData: map[string]system.GPUData{
|
||||||
|
"gpu0": {
|
||||||
|
Name: "RTX 4090",
|
||||||
|
Temperature: 60.0,
|
||||||
|
MemoryUsed: 4.0,
|
||||||
|
MemoryTotal: 24.0,
|
||||||
|
Usage: 30.0,
|
||||||
|
Power: 200.0,
|
||||||
|
Count: 1.0,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"3D": 50.0,
|
||||||
|
"Video": 10.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
GPUData: map[string]system.GPUData{
|
||||||
|
"gpu0": {
|
||||||
|
Name: "RTX 4090",
|
||||||
|
Temperature: 80.0,
|
||||||
|
MemoryUsed: 8.0,
|
||||||
|
MemoryTotal: 24.0,
|
||||||
|
Usage: 70.0,
|
||||||
|
Power: 300.0,
|
||||||
|
Count: 1.0,
|
||||||
|
Engines: map[string]float64{
|
||||||
|
"3D": 90.0,
|
||||||
|
"Video": 30.0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.GPUData)
|
||||||
|
gpu := result.GPUData["gpu0"]
|
||||||
|
assert.Equal(t, "RTX 4090", gpu.Name)
|
||||||
|
assert.Equal(t, 70.0, gpu.Temperature)
|
||||||
|
assert.Equal(t, 6.0, gpu.MemoryUsed)
|
||||||
|
assert.Equal(t, 24.0, gpu.MemoryTotal)
|
||||||
|
assert.Equal(t, 50.0, gpu.Usage)
|
||||||
|
assert.Equal(t, 250.0, gpu.Power)
|
||||||
|
assert.Equal(t, 1.0, gpu.Count)
|
||||||
|
require.NotNil(t, gpu.Engines)
|
||||||
|
assert.Equal(t, 70.0, gpu.Engines["3D"])
|
||||||
|
assert.Equal(t, 20.0, gpu.Engines["Video"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_MultipleGPUs(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
GPUData: map[string]system.GPUData{
|
||||||
|
"gpu0": {Name: "GPU A", Usage: 20.0, Temperature: 50.0},
|
||||||
|
"gpu1": {Name: "GPU B", Usage: 60.0, Temperature: 70.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
GPUData: map[string]system.GPUData{
|
||||||
|
"gpu0": {Name: "GPU A", Usage: 40.0, Temperature: 60.0},
|
||||||
|
"gpu1": {Name: "GPU B", Usage: 80.0, Temperature: 80.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.GPUData)
|
||||||
|
assert.Equal(t, 30.0, result.GPUData["gpu0"].Usage)
|
||||||
|
assert.Equal(t, 55.0, result.GPUData["gpu0"].Temperature)
|
||||||
|
assert.Equal(t, 70.0, result.GPUData["gpu1"].Usage)
|
||||||
|
assert.Equal(t, 75.0, result.GPUData["gpu1"].Temperature)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_CpuCoresUsage(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{10, 20, 30, 40}},
|
||||||
|
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{30, 40, 50, 60}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.CpuCoresUsage)
|
||||||
|
assert.Equal(t, system.Uint8Slice{20, 30, 40, 50}, result.CpuCoresUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that per-core usage rounds correctly (e.g., 15.5 -> 16 via math.Round).
|
||||||
|
func TestAverageSystemStatsSlice_CpuCoresUsageRounding(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{11}},
|
||||||
|
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{20}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.CpuCoresUsage)
|
||||||
|
// (11+20)/2 = 15.5, rounds to 16
|
||||||
|
assert.Equal(t, uint8(16), result.CpuCoresUsage[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_CpuBreakdown(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Cpu: 10.0, CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5}},
|
||||||
|
{Cpu: 20.0, CpuBreakdown: []float64{15.0, 7.0, 3.0, 1.5, 73.5}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
require.NotNil(t, result.CpuBreakdown)
|
||||||
|
assert.Equal(t, []float64{10.0, 5.0, 2.0, 1.0, 82.0}, result.CpuBreakdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that Battery[1] (charge state) uses the last record's value.
|
||||||
|
func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Cpu: 10.0, Battery: [2]uint8{100, 1}}, // charging
|
||||||
|
{Cpu: 20.0, Battery: [2]uint8{90, 0}}, // not charging
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, uint8(95), result.Battery[0])
|
||||||
|
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{Cpu: 10.0, Mem: 8.0},
|
||||||
|
{Cpu: 20.0, Mem: 8.0},
|
||||||
|
{Cpu: 30.0, Mem: 8.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, 20.0, result.Cpu)
|
||||||
|
assert.Equal(t, 8.0, result.Mem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests records where some have optional fields and others don't.
|
||||||
|
func TestAverageSystemStatsSlice_MixedOptionalFields(t *testing.T) {
|
||||||
|
input := []system.Stats{
|
||||||
|
{
|
||||||
|
Cpu: 10.0,
|
||||||
|
CpuCoresUsage: system.Uint8Slice{50, 60},
|
||||||
|
CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5},
|
||||||
|
GPUData: map[string]system.GPUData{
|
||||||
|
"gpu0": {Name: "GPU", Usage: 40.0},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Cpu: 20.0,
|
||||||
|
// No CpuCoresUsage, CpuBreakdown, or GPUData
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, 15.0, result.Cpu)
|
||||||
|
// CpuCoresUsage: only 1 record had it, so sum/2
|
||||||
|
require.NotNil(t, result.CpuCoresUsage)
|
||||||
|
assert.Equal(t, uint8(25), result.CpuCoresUsage[0])
|
||||||
|
assert.Equal(t, uint8(30), result.CpuCoresUsage[1])
|
||||||
|
// CpuBreakdown: only 1 record had it, so sum/2
|
||||||
|
require.NotNil(t, result.CpuBreakdown)
|
||||||
|
assert.Equal(t, 2.5, result.CpuBreakdown[0])
|
||||||
|
// GPUData: only 1 record had it, so sum/2
|
||||||
|
require.NotNil(t, result.GPUData)
|
||||||
|
assert.Equal(t, 20.0, result.GPUData["gpu0"].Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests with 10 records matching the common real-world case (10 x 1m -> 1 x 10m).
|
||||||
|
func TestAverageSystemStatsSlice_TenRecords(t *testing.T) {
|
||||||
|
input := make([]system.Stats, 10)
|
||||||
|
for i := range input {
|
||||||
|
input[i] = system.Stats{
|
||||||
|
Cpu: float64(i * 10), // 0, 10, 20, ..., 90
|
||||||
|
Mem: 16.0,
|
||||||
|
MemUsed: float64(4 + i), // 4, 5, 6, ..., 13
|
||||||
|
MemPct: float64(25 + i), // 25, 26, ..., 34
|
||||||
|
DiskTotal: 500.0,
|
||||||
|
DiskUsed: 250.0,
|
||||||
|
DiskPct: 50.0,
|
||||||
|
NetworkSent: float64(i),
|
||||||
|
NetworkRecv: float64(i * 2),
|
||||||
|
Bandwidth: [2]uint64{uint64(i * 1000), uint64(i * 2000)},
|
||||||
|
LoadAvg: [3]float64{float64(i), float64(i) * 0.5, float64(i) * 0.25},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageSystemStatsSlice(input)
|
||||||
|
|
||||||
|
assert.Equal(t, 45.0, result.Cpu) // avg of 0..90
|
||||||
|
assert.Equal(t, 16.0, result.Mem) // constant
|
||||||
|
assert.Equal(t, 8.5, result.MemUsed) // avg of 4..13
|
||||||
|
assert.Equal(t, 29.5, result.MemPct) // avg of 25..34
|
||||||
|
assert.Equal(t, 500.0, result.DiskTotal)
|
||||||
|
assert.Equal(t, 250.0, result.DiskUsed)
|
||||||
|
assert.Equal(t, 50.0, result.DiskPct)
|
||||||
|
assert.Equal(t, 4.5, result.NetworkSent)
|
||||||
|
assert.Equal(t, 9.0, result.NetworkRecv)
|
||||||
|
assert.Equal(t, [2]uint64{4500, 9000}, result.Bandwidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Container Stats Tests ---
|
||||||
|
|
||||||
|
func TestAverageContainerStatsSlice_Empty(t *testing.T) {
|
||||||
|
result := records.AverageContainerStatsSlice(nil)
|
||||||
|
assert.Equal(t, []container.Stats{}, result)
|
||||||
|
|
||||||
|
result = records.AverageContainerStatsSlice([][]container.Stats{})
|
||||||
|
assert.Equal(t, []container.Stats{}, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageContainerStatsSlice_SingleRecord(t *testing.T) {
|
||||||
|
input := [][]container.Stats{
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 5.0, Mem: 128.0, Bandwidth: [2]uint64{1000, 2000}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageContainerStatsSlice(input)
|
||||||
|
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "nginx", result[0].Name)
|
||||||
|
assert.Equal(t, 5.0, result[0].Cpu)
|
||||||
|
assert.Equal(t, 128.0, result[0].Mem)
|
||||||
|
assert.Equal(t, [2]uint64{1000, 2000}, result[0].Bandwidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageContainerStatsSlice_BasicAveraging(t *testing.T) {
|
||||||
|
input := [][]container.Stats{
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{1000, 2000}},
|
||||||
|
{Name: "redis", Cpu: 5.0, Mem: 64.0, Bandwidth: [2]uint64{500, 1000}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{3000, 4000}},
|
||||||
|
{Name: "redis", Cpu: 15.0, Mem: 128.0, Bandwidth: [2]uint64{1500, 2000}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageContainerStatsSlice(input)
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||||
|
|
||||||
|
require.Len(t, result, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, "nginx", result[0].Name)
|
||||||
|
assert.Equal(t, 15.0, result[0].Cpu)
|
||||||
|
assert.Equal(t, 150.0, result[0].Mem)
|
||||||
|
assert.Equal(t, [2]uint64{2000, 3000}, result[0].Bandwidth)
|
||||||
|
|
||||||
|
assert.Equal(t, "redis", result[1].Name)
|
||||||
|
assert.Equal(t, 10.0, result[1].Cpu)
|
||||||
|
assert.Equal(t, 96.0, result[1].Mem)
|
||||||
|
assert.Equal(t, [2]uint64{1000, 1500}, result[1].Bandwidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests containers that appear in some records but not all.
|
||||||
|
func TestAverageContainerStatsSlice_ContainerAppearsInSomeRecords(t *testing.T) {
|
||||||
|
input := [][]container.Stats{
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 10.0, Mem: 100.0},
|
||||||
|
{Name: "redis", Cpu: 5.0, Mem: 64.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 20.0, Mem: 200.0},
|
||||||
|
// redis not present
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageContainerStatsSlice(input)
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||||
|
|
||||||
|
require.Len(t, result, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, "nginx", result[0].Name)
|
||||||
|
assert.Equal(t, 15.0, result[0].Cpu)
|
||||||
|
assert.Equal(t, 150.0, result[0].Mem)
|
||||||
|
|
||||||
|
// redis: sum / count where count = total records (2), not records containing redis
|
||||||
|
assert.Equal(t, "redis", result[1].Name)
|
||||||
|
assert.Equal(t, 2.5, result[1].Cpu)
|
||||||
|
assert.Equal(t, 32.0, result[1].Mem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests backward compatibility with deprecated NetworkSent/NetworkRecv (MB) when Bandwidth is zero.
|
||||||
|
func TestAverageContainerStatsSlice_DeprecatedNetworkFields(t *testing.T) {
|
||||||
|
input := [][]container.Stats{
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 10.0, Mem: 100.0, NetworkSent: 1.0, NetworkRecv: 2.0}, // 1 MB, 2 MB
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 20.0, Mem: 200.0, NetworkSent: 3.0, NetworkRecv: 4.0}, // 3 MB, 4 MB
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageContainerStatsSlice(input)
|
||||||
|
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, "nginx", result[0].Name)
|
||||||
|
// avg sent = (1*1048576 + 3*1048576) / 2 = 2*1048576
|
||||||
|
assert.Equal(t, uint64(2*1048576), result[0].Bandwidth[0])
|
||||||
|
// avg recv = (2*1048576 + 4*1048576) / 2 = 3*1048576
|
||||||
|
assert.Equal(t, uint64(3*1048576), result[0].Bandwidth[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tests that when Bandwidth is set, deprecated NetworkSent/NetworkRecv are ignored.
|
||||||
|
func TestAverageContainerStatsSlice_MixedBandwidthAndDeprecated(t *testing.T) {
|
||||||
|
input := [][]container.Stats{
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{5000, 6000}, NetworkSent: 99.0, NetworkRecv: 99.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{7000, 8000}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageContainerStatsSlice(input)
|
||||||
|
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, uint64(6000), result[0].Bandwidth[0])
|
||||||
|
assert.Equal(t, uint64(7000), result[0].Bandwidth[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageContainerStatsSlice_ThreeRecords(t *testing.T) {
|
||||||
|
input := [][]container.Stats{
|
||||||
|
{{Name: "app", Cpu: 1.0, Mem: 100.0}},
|
||||||
|
{{Name: "app", Cpu: 2.0, Mem: 200.0}},
|
||||||
|
{{Name: "app", Cpu: 3.0, Mem: 300.0}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageContainerStatsSlice(input)
|
||||||
|
|
||||||
|
require.Len(t, result, 1)
|
||||||
|
assert.Equal(t, 2.0, result[0].Cpu)
|
||||||
|
assert.Equal(t, 200.0, result[0].Mem)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAverageContainerStatsSlice_ManyContainers(t *testing.T) {
|
||||||
|
input := [][]container.Stats{
|
||||||
|
{
|
||||||
|
{Name: "a", Cpu: 10.0, Mem: 100.0},
|
||||||
|
{Name: "b", Cpu: 20.0, Mem: 200.0},
|
||||||
|
{Name: "c", Cpu: 30.0, Mem: 300.0},
|
||||||
|
{Name: "d", Cpu: 40.0, Mem: 400.0},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
{Name: "a", Cpu: 20.0, Mem: 200.0},
|
||||||
|
{Name: "b", Cpu: 30.0, Mem: 300.0},
|
||||||
|
{Name: "c", Cpu: 40.0, Mem: 400.0},
|
||||||
|
{Name: "d", Cpu: 50.0, Mem: 500.0},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := records.AverageContainerStatsSlice(input)
|
||||||
|
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||||
|
|
||||||
|
require.Len(t, result, 4)
|
||||||
|
assert.Equal(t, 15.0, result[0].Cpu)
|
||||||
|
assert.Equal(t, 25.0, result[1].Cpu)
|
||||||
|
assert.Equal(t, 35.0, result[2].Cpu)
|
||||||
|
assert.Equal(t, 45.0, result[3].Cpu)
|
||||||
|
}
|
||||||
138
internal/records/records_deletion.go
Normal file
138
internal/records/records_deletion.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package records
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Delete old records
|
||||||
|
func (rm *RecordManager) DeleteOldRecords() {
|
||||||
|
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||||
|
err := deleteOldSystemStats(txApp)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error deleting old system stats", "err", err)
|
||||||
|
}
|
||||||
|
err = deleteOldContainerRecords(txApp)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error deleting old container records", "err", err)
|
||||||
|
}
|
||||||
|
err = deleteOldSystemdServiceRecords(txApp)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error deleting old systemd service records", "err", err)
|
||||||
|
}
|
||||||
|
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error deleting old alerts history", "err", err)
|
||||||
|
}
|
||||||
|
err = deleteOldQuietHours(txApp)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error deleting old quiet hours", "err", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old alerts history records
|
||||||
|
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||||
|
db := app.DB()
|
||||||
|
var users []struct {
|
||||||
|
Id string `db:"user"`
|
||||||
|
}
|
||||||
|
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, user := range users {
|
||||||
|
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes system_stats records older than what is displayed in the UI
|
||||||
|
func deleteOldSystemStats(app core.App) error {
|
||||||
|
// Collections to process
|
||||||
|
collections := [2]string{"system_stats", "container_stats"}
|
||||||
|
|
||||||
|
// Record types and their retention periods
|
||||||
|
type RecordDeletionData struct {
|
||||||
|
recordType string
|
||||||
|
retention time.Duration
|
||||||
|
}
|
||||||
|
recordData := []RecordDeletionData{
|
||||||
|
{recordType: "1m", retention: time.Hour}, // 1 hour
|
||||||
|
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
|
||||||
|
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
|
||||||
|
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
|
||||||
|
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
for _, collection := range collections {
|
||||||
|
// Build the WHERE clause
|
||||||
|
var conditionParts []string
|
||||||
|
var params dbx.Params = make(map[string]any)
|
||||||
|
for i := range recordData {
|
||||||
|
rd := recordData[i]
|
||||||
|
// Create parameterized condition for this record type
|
||||||
|
dateParam := fmt.Sprintf("date%d", i)
|
||||||
|
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
||||||
|
params[dateParam] = now.Add(-rd.retention)
|
||||||
|
}
|
||||||
|
// Combine conditions with OR
|
||||||
|
conditionStr := strings.Join(conditionParts, " OR ")
|
||||||
|
// Construct and execute the full raw query
|
||||||
|
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
||||||
|
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
||||||
|
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
||||||
|
func deleteOldSystemdServiceRecords(app core.App) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
||||||
|
|
||||||
|
// Delete systemd service records where updated < twentyMinutesAgo
|
||||||
|
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes container records that haven't been updated in the last 10 minutes
|
||||||
|
func deleteOldContainerRecords(app core.App) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||||
|
|
||||||
|
// Delete container records where updated < tenMinutesAgo
|
||||||
|
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete old container records: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deletes old quiet hours records where end date has passed
|
||||||
|
func deleteOldQuietHours(app core.App) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
428
internal/records/records_deletion_test.go
Normal file
428
internal/records/records_deletion_test.go
Normal file
@@ -0,0 +1,428 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package records_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/records"
|
||||||
|
"github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||||
|
func TestDeleteOldRecords(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
|
||||||
|
// Create test user for alerts history
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Create old system_stats records that should be deleted
|
||||||
|
var record *core.Record
|
||||||
|
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// created is autodate field, so we need to set it manually
|
||||||
|
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, record)
|
||||||
|
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||||
|
require.Equal(t, record.Get("system"), system.Id)
|
||||||
|
require.Equal(t, record.Get("type"), "1m")
|
||||||
|
|
||||||
|
// Create recent system_stats record that should be kept
|
||||||
|
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": "1m",
|
||||||
|
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||||
|
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create many alerts history records to trigger deletion
|
||||||
|
for i := range 260 { // More than countBeforeDeletion (250)
|
||||||
|
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count records before deletion
|
||||||
|
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
rm.DeleteOldRecords()
|
||||||
|
|
||||||
|
// Count records after deletion
|
||||||
|
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||||
|
require.NoError(t, err)
|
||||||
|
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify old system stats were deleted
|
||||||
|
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||||
|
|
||||||
|
// Verify alerts history was trimmed
|
||||||
|
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||||
|
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||||
|
func TestDeleteOldSystemStats(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test system
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Test data for different record types and their retention periods
|
||||||
|
testCases := []struct {
|
||||||
|
recordType string
|
||||||
|
retention time.Duration
|
||||||
|
shouldBeKept bool
|
||||||
|
ageFromNow time.Duration
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||||
|
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||||
|
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||||
|
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||||
|
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||||
|
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||||
|
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||||
|
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test records for both system_stats and container_stats
|
||||||
|
collections := []string{"system_stats", "container_stats"}
|
||||||
|
recordIds := make(map[string][]string)
|
||||||
|
|
||||||
|
for _, collection := range collections {
|
||||||
|
recordIds[collection] = make([]string, 0)
|
||||||
|
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordTime := now.Add(-tc.ageFromNow)
|
||||||
|
|
||||||
|
var stats string
|
||||||
|
if collection == "system_stats" {
|
||||||
|
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||||
|
} else {
|
||||||
|
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||||
|
}
|
||||||
|
|
||||||
|
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"type": tc.recordType,
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||||
|
err = hub.SaveNoValidate(record)
|
||||||
|
require.NoError(t, err)
|
||||||
|
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.DeleteOldSystemStats(hub)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
for _, collection := range collections {
|
||||||
|
for i, tc := range testCases {
|
||||||
|
recordId := recordIds[collection][i]
|
||||||
|
|
||||||
|
// Try to find the record
|
||||||
|
_, err := hub.FindRecordById(collection, recordId)
|
||||||
|
|
||||||
|
if tc.shouldBeKept {
|
||||||
|
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||||
|
} else {
|
||||||
|
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||||
|
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
// Create test users
|
||||||
|
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user1.Id, user2.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
user *core.Record
|
||||||
|
alertCount int
|
||||||
|
countToKeep int
|
||||||
|
countBeforeDeletion int
|
||||||
|
expectedAfterDeletion int
|
||||||
|
description string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "User with few alerts (below threshold)",
|
||||||
|
user: user1,
|
||||||
|
alertCount: 100,
|
||||||
|
countToKeep: 50,
|
||||||
|
countBeforeDeletion: 150,
|
||||||
|
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||||
|
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "User with many alerts (above threshold)",
|
||||||
|
user: user2,
|
||||||
|
alertCount: 300,
|
||||||
|
countToKeep: 100,
|
||||||
|
countBeforeDeletion: 200,
|
||||||
|
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||||
|
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
// Create alerts for this user
|
||||||
|
for i := 0; i < tc.alertCount; i++ {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": tc.user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count before deletion
|
||||||
|
countBefore, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||||
|
|
||||||
|
// Run deletion
|
||||||
|
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Count after deletion
|
||||||
|
countAfter, err := hub.CountRecords("alerts_history",
|
||||||
|
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||||
|
|
||||||
|
// If deletion occurred, verify the most recent records were kept
|
||||||
|
if tc.expectedAfterDeletion < tc.alertCount {
|
||||||
|
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||||
|
"user = {:user}",
|
||||||
|
"-created", // Order by created DESC
|
||||||
|
tc.countToKeep,
|
||||||
|
0,
|
||||||
|
map[string]any{"user": tc.user.Id})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||||
|
|
||||||
|
// Verify records are in descending order by created time
|
||||||
|
for i := 1; i < len(records); i++ {
|
||||||
|
prev := records[i-1].GetDateTime("created").Time()
|
||||||
|
curr := records[i].GetDateTime("created").Time()
|
||||||
|
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||||
|
"Records should be ordered by created time (newest first)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||||
|
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||||
|
// Create user with few alerts
|
||||||
|
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Create only 5 alerts (well below threshold)
|
||||||
|
for i := range 5 {
|
||||||
|
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||||
|
"user": user.Id,
|
||||||
|
"name": "CPU",
|
||||||
|
"value": i + 1,
|
||||||
|
"system": system.Id,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should not error and should not delete anything
|
||||||
|
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
count, err := hub.CountRecords("alerts_history")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||||
|
// Clear any existing alerts
|
||||||
|
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should not error with empty table
|
||||||
|
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
||||||
|
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
||||||
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer hub.Cleanup()
|
||||||
|
|
||||||
|
rm := records.NewRecordManager(hub)
|
||||||
|
|
||||||
|
// Create test user and system
|
||||||
|
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||||
|
"name": "test-system",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": "45876",
|
||||||
|
"status": "up",
|
||||||
|
"users": []string{user.Id},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Create old systemd service records that should be deleted (older than 20 minutes)
|
||||||
|
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "nginx.service",
|
||||||
|
"state": 0, // Active
|
||||||
|
"sub": 1, // Running
|
||||||
|
"cpu": 5.0,
|
||||||
|
"cpuPeak": 10.0,
|
||||||
|
"memory": 1024000,
|
||||||
|
"memPeak": 2048000,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Set updated time to 25 minutes ago (should be deleted)
|
||||||
|
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
||||||
|
err = hub.SaveNoValidate(oldRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create recent systemd service record that should be kept (within 20 minutes)
|
||||||
|
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||||
|
"system": system.Id,
|
||||||
|
"name": "apache.service",
|
||||||
|
"state": 1, // Inactive
|
||||||
|
"sub": 0, // Dead
|
||||||
|
"cpu": 2.0,
|
||||||
|
"cpuPeak": 3.0,
|
||||||
|
"memory": 512000,
|
||||||
|
"memPeak": 1024000,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
// Set updated time to 10 minutes ago (should be kept)
|
||||||
|
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
||||||
|
err = hub.SaveNoValidate(recentRecord)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Count records before deletion
|
||||||
|
countBefore, err := hub.CountRecords("systemd_services")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
||||||
|
|
||||||
|
// Run deletion via RecordManager
|
||||||
|
rm.DeleteOldRecords()
|
||||||
|
|
||||||
|
// Count records after deletion
|
||||||
|
countAfter, err := hub.CountRecords("systemd_services")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
||||||
|
|
||||||
|
// Verify the correct record was kept
|
||||||
|
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
||||||
|
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
||||||
|
}
|
||||||
@@ -3,430 +3,15 @@
|
|||||||
package records_test
|
package records_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/records"
|
"github.com/henrygd/beszel/internal/records"
|
||||||
"github.com/henrygd/beszel/internal/tests"
|
"github.com/henrygd/beszel/internal/tests"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
|
||||||
func TestDeleteOldRecords(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
rm := records.NewRecordManager(hub)
|
|
||||||
|
|
||||||
// Create test user for alerts history
|
|
||||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create test system
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
// Create old system_stats records that should be deleted
|
|
||||||
var record *core.Record
|
|
||||||
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// created is autodate field, so we need to set it manually
|
|
||||||
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
|
||||||
err = hub.SaveNoValidate(record)
|
|
||||||
require.NoError(t, err)
|
|
||||||
require.NotNil(t, record)
|
|
||||||
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
|
||||||
require.Equal(t, record.Get("system"), system.Id)
|
|
||||||
require.Equal(t, record.Get("type"), "1m")
|
|
||||||
|
|
||||||
// Create recent system_stats record that should be kept
|
|
||||||
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"type": "1m",
|
|
||||||
"stats": `{"cpu": 30.0, "mem": 512}`,
|
|
||||||
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create many alerts history records to trigger deletion
|
|
||||||
for i := range 260 { // More than countBeforeDeletion (250)
|
|
||||||
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"name": "CPU",
|
|
||||||
"value": i + 1,
|
|
||||||
"system": system.Id,
|
|
||||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count records before deletion
|
|
||||||
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
|
||||||
require.NoError(t, err)
|
|
||||||
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Run deletion
|
|
||||||
rm.DeleteOldRecords()
|
|
||||||
|
|
||||||
// Count records after deletion
|
|
||||||
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
|
||||||
require.NoError(t, err)
|
|
||||||
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify old system stats were deleted
|
|
||||||
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
|
||||||
|
|
||||||
// Verify alerts history was trimmed
|
|
||||||
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
|
||||||
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
|
||||||
func TestDeleteOldSystemStats(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create test system
|
|
||||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
// Test data for different record types and their retention periods
|
|
||||||
testCases := []struct {
|
|
||||||
recordType string
|
|
||||||
retention time.Duration
|
|
||||||
shouldBeKept bool
|
|
||||||
ageFromNow time.Duration
|
|
||||||
description string
|
|
||||||
}{
|
|
||||||
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
|
||||||
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
|
||||||
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
|
||||||
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
|
||||||
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
|
||||||
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
|
||||||
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
|
||||||
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
|
||||||
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
|
||||||
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create test records for both system_stats and container_stats
|
|
||||||
collections := []string{"system_stats", "container_stats"}
|
|
||||||
recordIds := make(map[string][]string)
|
|
||||||
|
|
||||||
for _, collection := range collections {
|
|
||||||
recordIds[collection] = make([]string, 0)
|
|
||||||
|
|
||||||
for i, tc := range testCases {
|
|
||||||
recordTime := now.Add(-tc.ageFromNow)
|
|
||||||
|
|
||||||
var stats string
|
|
||||||
if collection == "system_stats" {
|
|
||||||
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
|
||||||
} else {
|
|
||||||
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
|
||||||
}
|
|
||||||
|
|
||||||
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"type": tc.recordType,
|
|
||||||
"stats": stats,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
|
||||||
err = hub.SaveNoValidate(record)
|
|
||||||
require.NoError(t, err)
|
|
||||||
recordIds[collection] = append(recordIds[collection], record.Id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run deletion
|
|
||||||
err = records.DeleteOldSystemStats(hub)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Verify results
|
|
||||||
for _, collection := range collections {
|
|
||||||
for i, tc := range testCases {
|
|
||||||
recordId := recordIds[collection][i]
|
|
||||||
|
|
||||||
// Try to find the record
|
|
||||||
_, err := hub.FindRecordById(collection, recordId)
|
|
||||||
|
|
||||||
if tc.shouldBeKept {
|
|
||||||
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
|
||||||
} else {
|
|
||||||
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
|
||||||
func TestDeleteOldAlertsHistory(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
// Create test users
|
|
||||||
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user1.Id, user2.Id},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
testCases := []struct {
|
|
||||||
name string
|
|
||||||
user *core.Record
|
|
||||||
alertCount int
|
|
||||||
countToKeep int
|
|
||||||
countBeforeDeletion int
|
|
||||||
expectedAfterDeletion int
|
|
||||||
description string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "User with few alerts (below threshold)",
|
|
||||||
user: user1,
|
|
||||||
alertCount: 100,
|
|
||||||
countToKeep: 50,
|
|
||||||
countBeforeDeletion: 150,
|
|
||||||
expectedAfterDeletion: 100, // No deletion because below threshold
|
|
||||||
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "User with many alerts (above threshold)",
|
|
||||||
user: user2,
|
|
||||||
alertCount: 300,
|
|
||||||
countToKeep: 100,
|
|
||||||
countBeforeDeletion: 200,
|
|
||||||
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
|
||||||
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range testCases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
// Create alerts for this user
|
|
||||||
for i := 0; i < tc.alertCount; i++ {
|
|
||||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
|
||||||
"user": tc.user.Id,
|
|
||||||
"name": "CPU",
|
|
||||||
"value": i + 1,
|
|
||||||
"system": system.Id,
|
|
||||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count before deletion
|
|
||||||
countBefore, err := hub.CountRecords("alerts_history",
|
|
||||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
|
||||||
|
|
||||||
// Run deletion
|
|
||||||
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Count after deletion
|
|
||||||
countAfter, err := hub.CountRecords("alerts_history",
|
|
||||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
|
||||||
|
|
||||||
// If deletion occurred, verify the most recent records were kept
|
|
||||||
if tc.expectedAfterDeletion < tc.alertCount {
|
|
||||||
records, err := hub.FindRecordsByFilter("alerts_history",
|
|
||||||
"user = {:user}",
|
|
||||||
"-created", // Order by created DESC
|
|
||||||
tc.countToKeep,
|
|
||||||
0,
|
|
||||||
map[string]any{"user": tc.user.Id})
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
|
||||||
|
|
||||||
// Verify records are in descending order by created time
|
|
||||||
for i := 1; i < len(records); i++ {
|
|
||||||
prev := records[i-1].GetDateTime("created").Time()
|
|
||||||
curr := records[i].GetDateTime("created").Time()
|
|
||||||
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
|
||||||
"Records should be ordered by created time (newest first)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
|
||||||
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
t.Run("No users with excessive alerts", func(t *testing.T) {
|
|
||||||
// Create user with few alerts
|
|
||||||
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
})
|
|
||||||
|
|
||||||
// Create only 5 alerts (well below threshold)
|
|
||||||
for i := range 5 {
|
|
||||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
|
||||||
"user": user.Id,
|
|
||||||
"name": "CPU",
|
|
||||||
"value": i + 1,
|
|
||||||
"system": system.Id,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should not error and should not delete anything
|
|
||||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
count, err := hub.CountRecords("alerts_history")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(5), count, "All alerts should remain")
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("Empty alerts_history table", func(t *testing.T) {
|
|
||||||
// Clear any existing alerts
|
|
||||||
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Should not error with empty table
|
|
||||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
|
||||||
require.NoError(t, err)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
|
||||||
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
|
||||||
require.NoError(t, err)
|
|
||||||
defer hub.Cleanup()
|
|
||||||
|
|
||||||
rm := records.NewRecordManager(hub)
|
|
||||||
|
|
||||||
// Create test user and system
|
|
||||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
|
||||||
"name": "test-system",
|
|
||||||
"host": "localhost",
|
|
||||||
"port": "45876",
|
|
||||||
"status": "up",
|
|
||||||
"users": []string{user.Id},
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
now := time.Now().UTC()
|
|
||||||
|
|
||||||
// Create old systemd service records that should be deleted (older than 20 minutes)
|
|
||||||
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "nginx.service",
|
|
||||||
"state": 0, // Active
|
|
||||||
"sub": 1, // Running
|
|
||||||
"cpu": 5.0,
|
|
||||||
"cpuPeak": 10.0,
|
|
||||||
"memory": 1024000,
|
|
||||||
"memPeak": 2048000,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Set updated time to 25 minutes ago (should be deleted)
|
|
||||||
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
|
||||||
err = hub.SaveNoValidate(oldRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Create recent systemd service record that should be kept (within 20 minutes)
|
|
||||||
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
|
||||||
"system": system.Id,
|
|
||||||
"name": "apache.service",
|
|
||||||
"state": 1, // Inactive
|
|
||||||
"sub": 0, // Dead
|
|
||||||
"cpu": 2.0,
|
|
||||||
"cpuPeak": 3.0,
|
|
||||||
"memory": 512000,
|
|
||||||
"memPeak": 1024000,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
// Set updated time to 10 minutes ago (should be kept)
|
|
||||||
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
|
||||||
err = hub.SaveNoValidate(recentRecord)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Count records before deletion
|
|
||||||
countBefore, err := hub.CountRecords("systemd_services")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
|
||||||
|
|
||||||
// Run deletion via RecordManager
|
|
||||||
rm.DeleteOldRecords()
|
|
||||||
|
|
||||||
// Count records after deletion
|
|
||||||
countAfter, err := hub.CountRecords("systemd_services")
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
|
||||||
|
|
||||||
// Verify the correct record was kept
|
|
||||||
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
|
||||||
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecordManagerCreation tests RecordManager creation
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
func TestRecordManagerCreation(t *testing.T) {
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
|||||||
@@ -22,11 +22,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = "{info}"
|
||||||
BASE_PATH: "%BASE_URL%",
|
|
||||||
HUB_VERSION: "{{V}}",
|
|
||||||
HUB_URL: "{{HUB_URL}}"
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { $authenticated } from "@/lib/stores"
|
import { $authenticated } from "@/lib/stores"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import { $router, Link, prependBasePath } from "../router"
|
import { $router, Link, basePath, prependBasePath } from "../router"
|
||||||
import { toast } from "../ui/use-toast"
|
import { toast } from "../ui/use-toast"
|
||||||
import { OtpInputForm } from "./otp-forms"
|
import { OtpInputForm } from "./otp-forms"
|
||||||
|
|
||||||
@@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({
|
|||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const showLoginFaliedToast = (description?: string) => {
|
export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
|
||||||
description ||= t`Please check your credentials and try again`
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Login attempt failed`,
|
title: t`Login attempt failed`,
|
||||||
description,
|
description,
|
||||||
@@ -130,10 +129,6 @@ export function UserAuthForm({
|
|||||||
[isFirstRun]
|
[isFirstRun]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!authMethods) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProviders = authMethods.oauth2.providers ?? []
|
const authProviders = authMethods.oauth2.providers ?? []
|
||||||
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
||||||
const passwordEnabled = authMethods.password.enabled
|
const passwordEnabled = authMethods.password.enabled
|
||||||
@@ -142,6 +137,12 @@ export function UserAuthForm({
|
|||||||
|
|
||||||
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
||||||
setIsOauthLoading(true)
|
setIsOauthLoading(true)
|
||||||
|
|
||||||
|
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
|
||||||
|
redirectToOauthProvider(provider)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const oAuthOpts: OAuth2AuthConfig = {
|
const oAuthOpts: OAuth2AuthConfig = {
|
||||||
provider: provider.name,
|
provider: provider.name,
|
||||||
}
|
}
|
||||||
@@ -150,10 +151,7 @@ export function UserAuthForm({
|
|||||||
const authWindow = window.open()
|
const authWindow = window.open()
|
||||||
if (!authWindow) {
|
if (!authWindow) {
|
||||||
setIsOauthLoading(false)
|
setIsOauthLoading(false)
|
||||||
toast({
|
showLoginFaliedToast(t`Please enable pop-ups for this site`)
|
||||||
title: t`Error`,
|
|
||||||
description: t`Please enable pop-ups for this site`,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
oAuthOpts.urlCallback = (url) => {
|
oAuthOpts.urlCallback = (url) => {
|
||||||
@@ -171,16 +169,57 @@ export function UserAuthForm({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirects the user to the OAuth provider's authentication page in the same window.
|
||||||
|
* Requires the app's base URL to be registered as a redirect URI with the OAuth provider.
|
||||||
|
*/
|
||||||
|
function redirectToOauthProvider(provider: AuthProviderInfo) {
|
||||||
|
const url = new URL(provider.authURL)
|
||||||
|
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
|
||||||
|
sessionStorage.setItem("provider", JSON.stringify(provider))
|
||||||
|
window.location.href = url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// auto login if password disabled and only one auth provider
|
// handle redirect-based OAuth callback if we have a code
|
||||||
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
const params = new URLSearchParams(window.location.search)
|
||||||
// Add a small timeout to ensure browser is ready to handle popups
|
const code = params.get("code")
|
||||||
setTimeout(() => {
|
if (code) {
|
||||||
loginWithOauth(authProviders[0], true)
|
const state = params.get("state")
|
||||||
}, 300)
|
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
|
||||||
|
if (!state || provider.state !== state) {
|
||||||
|
showLoginFaliedToast()
|
||||||
|
} else {
|
||||||
|
setIsOauthLoading(true)
|
||||||
|
window.history.replaceState({}, "", window.location.pathname)
|
||||||
|
pb.collection("users")
|
||||||
|
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
|
||||||
|
.then(() => $authenticated.set(pb.authStore.isValid))
|
||||||
|
.catch((e: unknown) => showLoginFaliedToast((e as Error).message))
|
||||||
|
.finally(() => setIsOauthLoading(false))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// auto login if password disabled and only one auth provider
|
||||||
|
if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
||||||
|
// Add a small timeout to ensure browser is ready to handle popups
|
||||||
|
setTimeout(() => loginWithOauth(authProviders[0], false), 300)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// refresh auth if not in above states (required for trusted auth header)
|
||||||
|
pb.collection("users")
|
||||||
|
.authRefresh()
|
||||||
|
.then((res) => {
|
||||||
|
pb.authStore.save(res.token, res.record)
|
||||||
|
$authenticated.set(!!pb.authStore.isValid)
|
||||||
|
})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
if (!authMethods) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (otpId && mfaId) {
|
if (otpId && mfaId) {
|
||||||
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
||||||
}
|
}
|
||||||
@@ -248,7 +287,7 @@ export function UserAuthForm({
|
|||||||
)}
|
)}
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
{/* honeypot */}
|
{/* honeypot */}
|
||||||
<label htmlFor="website"></label>
|
<label htmlFor="website">Website</label>
|
||||||
<input
|
<input
|
||||||
id="website"
|
id="website"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import { MoonStarIcon, SunIcon } from "lucide-react"
|
import { MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react"
|
||||||
import { useTheme } from "@/components/theme-provider"
|
import { useTheme } from "@/components/theme-provider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
const themes = ["light", "dark", "system"] as const
|
||||||
|
const icons = [SunIcon, MoonStarIcon, SunMoonIcon] as const
|
||||||
|
|
||||||
export function ModeToggle() {
|
export function ModeToggle() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
const currentIndex = themes.indexOf(theme)
|
||||||
|
const Icon = icons[currentIndex]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={"ghost"}
|
variant={"ghost"}
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={t`Toggle theme`}
|
aria-label={t`Switch theme`}
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
onClick={() => setTheme(themes[(currentIndex + 1) % themes.length])}
|
||||||
>
|
>
|
||||||
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
|
<Icon
|
||||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
|
className={cn(
|
||||||
|
"animate-in fade-in spin-in-[-30deg] duration-200",
|
||||||
|
currentIndex === 2 ? "size-[1.35rem]" : "size-[1.2rem]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<Trans>Toggle theme</Trans>
|
<Trans>Switch theme</Trans>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { isAdmin, pb } from "@/lib/api"
|
|||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
import { QuietHours } from "./quiet-hours"
|
import { QuietHours } from "./quiet-hours"
|
||||||
|
import type { ClientResponseError } from "pocketbase"
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -59,10 +60,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
try {
|
try {
|
||||||
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
||||||
await saveSettings(parsedData)
|
await saveSettings(parsedData)
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to save settings`,
|
title: t`Failed to save settings`,
|
||||||
description: e.message,
|
description: (e as Error).message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -136,12 +137,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="button" variant="outline" className="h-10 shrink-0" onClick={addWebhook}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 shrink-0"
|
|
||||||
onClick={addWebhook}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
<span className="ms-1">
|
<span className="ms-1">
|
||||||
<Trans>Add URL</Trans>
|
<Trans>Add URL</Trans>
|
||||||
@@ -180,25 +176,34 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showTestNotificationError(msg: string) {
|
||||||
|
toast({
|
||||||
|
title: t`Error`,
|
||||||
|
description: msg ?? t`Failed to send test notification`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const sendTestNotification = async () => {
|
const sendTestNotification = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
try {
|
||||||
if ("err" in res && !res.err) {
|
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||||
toast({
|
if ("err" in res && !res.err) {
|
||||||
title: t`Test notification sent`,
|
toast({
|
||||||
description: t`Check your notification service`,
|
title: t`Test notification sent`,
|
||||||
})
|
description: t`Check your notification service`,
|
||||||
} else {
|
})
|
||||||
toast({
|
} else {
|
||||||
title: t`Error`,
|
showTestNotificationError(res.err)
|
||||||
description: res.err ?? t`Failed to send test notification`,
|
}
|
||||||
variant: "destructive",
|
} catch (e: unknown) {
|
||||||
})
|
showTestNotificationError((e as ClientResponseError).data?.message)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -110,20 +110,23 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
|
|
||||||
// match filter value against name or translated status
|
// match filter value against name or translated status
|
||||||
return (row, _, newFilterInput) => {
|
return (row, _, newFilterInput) => {
|
||||||
const { name, status } = row.original
|
const sys = row.original
|
||||||
|
if (sys.host.includes(newFilterInput) || sys.info.v?.includes(newFilterInput)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
if (newFilterInput !== filterInput) {
|
if (newFilterInput !== filterInput) {
|
||||||
filterInput = newFilterInput
|
filterInput = newFilterInput
|
||||||
filterInputLower = newFilterInput.toLowerCase()
|
filterInputLower = newFilterInput.toLowerCase()
|
||||||
}
|
}
|
||||||
let nameLower = nameCache.get(name)
|
let nameLower = nameCache.get(sys.name)
|
||||||
if (nameLower === undefined) {
|
if (nameLower === undefined) {
|
||||||
nameLower = name.toLowerCase()
|
nameLower = sys.name.toLowerCase()
|
||||||
nameCache.set(name, nameLower)
|
nameCache.set(sys.name, nameLower)
|
||||||
}
|
}
|
||||||
if (nameLower.includes(filterInputLower)) {
|
if (nameLower.includes(filterInputLower)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const statusLower = statusTranslations[status as keyof typeof statusTranslations]
|
const statusLower = statusTranslations[sys.status as keyof typeof statusTranslations]
|
||||||
return statusLower?.includes(filterInputLower) || false
|
return statusLower?.includes(filterInputLower) || false
|
||||||
}
|
}
|
||||||
})(),
|
})(),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: fr\n"
|
"Language: fr\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
"PO-Revision-Date: 2026-05-14 12:15\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: French\n"
|
"Language-Team: French\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
@@ -57,11 +57,11 @@ msgstr "1 heure"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr ""
|
msgstr "1 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 minute"
|
msgid "1 minute"
|
||||||
msgstr ""
|
msgstr "1 minute"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 week"
|
msgid "1 week"
|
||||||
@@ -74,7 +74,7 @@ msgstr "12 heures"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "15 min"
|
msgid "15 min"
|
||||||
msgstr ""
|
msgstr "15 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
@@ -87,7 +87,7 @@ msgstr "30 jours"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "5 min"
|
msgid "5 min"
|
||||||
msgstr ""
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
@@ -95,14 +95,14 @@ msgstr ""
|
|||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr ""
|
msgstr "Actions"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr ""
|
msgstr "Active"
|
||||||
|
|
||||||
#: src/components/active-alerts.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Active Alerts"
|
msgid "Active Alerts"
|
||||||
@@ -137,7 +137,7 @@ msgstr "Ajuster la largeur de la mise en page principale"
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr ""
|
msgstr "Admin"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "After"
|
msgid "After"
|
||||||
@@ -149,7 +149,7 @@ msgstr "Après avoir défini les variables d'environnement, redémarrez votre hu
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr ""
|
msgstr "Agent"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
@@ -248,7 +248,7 @@ msgstr "Bande passante"
|
|||||||
#. Battery label in systems table header
|
#. Battery label in systems table header
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Bat"
|
msgid "Bat"
|
||||||
msgstr ""
|
msgstr "Bat"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/sensor-charts.tsx
|
#: src/components/routes/system/charts/sensor-charts.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -289,7 +289,7 @@ msgstr "Binaire"
|
|||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr ""
|
msgstr "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Boot state"
|
msgid "Boot state"
|
||||||
@@ -298,7 +298,7 @@ msgstr "État de démarrage"
|
|||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
msgstr ""
|
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/memory-charts.tsx
|
#: src/components/routes/system/charts/memory-charts.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
@@ -336,7 +336,7 @@ msgstr "Attention - perte de données potentielle"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Celsius (°C)"
|
msgid "Celsius (°C)"
|
||||||
msgstr ""
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change display units for metrics."
|
msgid "Change display units for metrics."
|
||||||
@@ -348,7 +348,7 @@ msgstr "Modifier les options générales de l'application."
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/sensor-charts.tsx
|
#: src/components/routes/system/charts/sensor-charts.tsx
|
||||||
msgid "Charge"
|
msgid "Charge"
|
||||||
msgstr ""
|
msgstr "Charge"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
@@ -496,7 +496,7 @@ msgstr "Cœur"
|
|||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr ""
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Cores"
|
msgid "CPU Cores"
|
||||||
@@ -554,7 +554,7 @@ msgstr "État actuel"
|
|||||||
#. Power Cycles
|
#. Power Cycles
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Cycles"
|
msgid "Cycles"
|
||||||
msgstr ""
|
msgstr "Cycles"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
@@ -583,7 +583,7 @@ msgstr "Supprimer l'empreinte"
|
|||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Description"
|
msgid "Description"
|
||||||
msgstr ""
|
msgstr "Description"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table.tsx
|
#: src/components/containers-table/containers-table.tsx
|
||||||
msgid "Detail"
|
msgid "Detail"
|
||||||
@@ -641,7 +641,7 @@ msgstr "Entrée/Sortie réseau Docker"
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Documentation"
|
msgid "Documentation"
|
||||||
msgstr ""
|
msgstr "Documentation"
|
||||||
|
|
||||||
#. Context: System is down
|
#. Context: System is down
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
@@ -677,7 +677,7 @@ msgstr "Modifier {foo}"
|
|||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
#: src/components/login/otp-forms.tsx
|
#: src/components/login/otp-forms.tsx
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr ""
|
msgstr "Email"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Email notifications"
|
msgid "Email notifications"
|
||||||
@@ -772,7 +772,7 @@ msgstr "Exportez la configuration actuelle de vos systèmes."
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr ""
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Failed"
|
msgid "Failed"
|
||||||
@@ -854,11 +854,11 @@ msgstr "Général"
|
|||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Global"
|
msgid "Global"
|
||||||
msgstr ""
|
msgstr "Global"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "GPU"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -938,7 +938,7 @@ msgstr "Si vous avez perdu le mot de passe de votre compte administrateur, vous
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Docker image"
|
msgctxt "Docker image"
|
||||||
msgid "Image"
|
msgid "Image"
|
||||||
msgstr ""
|
msgstr "Image"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Inactive"
|
msgid "Inactive"
|
||||||
@@ -1043,7 +1043,7 @@ msgstr "Guide pour une installation manuelle"
|
|||||||
#. Chart select field. Please try to keep this short.
|
#. Chart select field. Please try to keep this short.
|
||||||
#: src/components/routes/system/chart-card.tsx
|
#: src/components/routes/system/chart-card.tsx
|
||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr ""
|
msgstr "Max 1 min"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
@@ -1138,7 +1138,7 @@ msgstr "Aucun système trouvé."
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Notifications"
|
msgid "Notifications"
|
||||||
msgstr ""
|
msgstr "Notifications"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "OAuth 2 / OIDC support"
|
msgid "OAuth 2 / OIDC support"
|
||||||
@@ -1181,7 +1181,7 @@ msgstr "Écraser les alertes existantes"
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
msgid "Page"
|
msgid "Page"
|
||||||
msgstr ""
|
msgstr "Page"
|
||||||
|
|
||||||
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
#. placeholder {0}: table.getState().pagination.pageIndex + 1
|
||||||
#. placeholder {1}: table.getPageCount()
|
#. placeholder {1}: table.getPageCount()
|
||||||
@@ -1216,7 +1216,7 @@ msgstr "Passé"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr ""
|
msgstr "Pause"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
@@ -1245,7 +1245,7 @@ msgstr "Pourcentage de temps passé dans chaque état"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Permanent"
|
msgid "Permanent"
|
||||||
msgstr ""
|
msgstr "Permanent"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Persistence"
|
msgid "Persistence"
|
||||||
@@ -1286,12 +1286,12 @@ msgstr "Veuillez vous connecter à votre compte"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr ""
|
msgstr "Port"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Ports"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1482,7 +1482,7 @@ msgstr "Détails du service"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Services"
|
msgid "Services"
|
||||||
msgstr ""
|
msgstr "Services"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Set percentage thresholds for meter colors."
|
msgid "Set percentage thresholds for meter colors."
|
||||||
@@ -1665,7 +1665,7 @@ msgstr "Changer le thème"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr ""
|
msgstr "Token"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
@@ -1684,7 +1684,7 @@ msgstr "Les tokens et les empreintes sont utilisés pour authentifier les connex
|
|||||||
#: src/components/ui/chart.tsx
|
#: src/components/ui/chart.tsx
|
||||||
#: src/components/ui/chart.tsx
|
#: src/components/ui/chart.tsx
|
||||||
msgid "Total"
|
msgid "Total"
|
||||||
msgstr ""
|
msgstr "Total"
|
||||||
|
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "Total data received for each interface"
|
msgid "Total data received for each interface"
|
||||||
@@ -1697,7 +1697,7 @@ msgstr "Données totales envoyées pour chaque interface"
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O"
|
msgctxt "Disk I/O"
|
||||||
msgid "Total time spent on read/write (can exceed 100%)"
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
msgstr ""
|
msgstr "Temps total passé en lecture/écriture (peut dépasser 100%)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1760,7 +1760,7 @@ msgstr "Déclenchement lorsque l'utilisation de tout disque dépasse un seuil"
|
|||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Type"
|
msgid "Type"
|
||||||
msgstr ""
|
msgstr "Type"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Unit file"
|
msgid "Unit file"
|
||||||
@@ -1898,7 +1898,7 @@ msgstr "Lorsqu'il est activé, ce jeton permet aux agents de s'enregistrer autom
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
|
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
|
||||||
msgstr "En utilisant POST, chaque heartbeat inclut une charge utile JSON avec un résumé de l'état du sistema, la liste des systèmes en panne et les alertes déclenchées."
|
msgstr "En utilisant POST, chaque heartbeat inclut une charge utile JSON avec un résumé de l'état du système, la liste des systèmes en panne et les alertes déclenchées."
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Oui"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Vos paramètres utilisateur ont été mis à jour."
|
msgstr "Vos paramètres utilisateur ont été mis à jour."
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: it\n"
|
"Language: it\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
"PO-Revision-Date: 2026-04-17 09:26\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Italian\n"
|
"Language-Team: Italian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -57,7 +57,7 @@ msgstr "1 ora"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr ""
|
msgstr "1 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 minute"
|
msgid "1 minute"
|
||||||
@@ -74,7 +74,7 @@ msgstr "12 ore"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "15 min"
|
msgid "15 min"
|
||||||
msgstr ""
|
msgstr "15 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
@@ -87,7 +87,7 @@ msgstr "30 giorni"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "5 min"
|
msgid "5 min"
|
||||||
msgstr ""
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
@@ -248,7 +248,7 @@ msgstr "Larghezza di banda"
|
|||||||
#. Battery label in systems table header
|
#. Battery label in systems table header
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Bat"
|
msgid "Bat"
|
||||||
msgstr ""
|
msgstr "Batt"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/sensor-charts.tsx
|
#: src/components/routes/system/charts/sensor-charts.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -336,7 +336,7 @@ msgstr "Attenzione - possibile perdita di dati"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Celsius (°C)"
|
msgid "Celsius (°C)"
|
||||||
msgstr ""
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change display units for metrics."
|
msgid "Change display units for metrics."
|
||||||
@@ -490,13 +490,13 @@ msgstr "Copia YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Interne"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr ""
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Cores"
|
msgid "CPU Cores"
|
||||||
@@ -624,7 +624,7 @@ msgstr "Utilizzo del disco di {extraFsName}"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Display"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -677,7 +677,7 @@ msgstr "Modifica {foo}"
|
|||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
#: src/components/login/otp-forms.tsx
|
#: src/components/login/otp-forms.tsx
|
||||||
msgid "Email"
|
msgid "Email"
|
||||||
msgstr ""
|
msgstr "Email"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Email notifications"
|
msgid "Email notifications"
|
||||||
@@ -772,7 +772,7 @@ msgstr "Esporta la configurazione attuale dei tuoi sistemi."
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr ""
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Failed"
|
msgid "Failed"
|
||||||
@@ -824,7 +824,7 @@ msgstr "Impronta digitale"
|
|||||||
|
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Firmware"
|
msgid "Firmware"
|
||||||
msgstr ""
|
msgstr "Firmware"
|
||||||
|
|
||||||
#: src/components/alerts/alerts-sheet.tsx
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
|
||||||
@@ -858,7 +858,7 @@ msgstr "Globale"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "GPU"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -883,7 +883,7 @@ msgstr "Stato"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Hearthbeat"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -901,7 +901,7 @@ msgstr "Comando Homebrew"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Host / IP"
|
msgid "Host / IP"
|
||||||
msgstr ""
|
msgstr "Host / IP"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "HTTP Method"
|
msgid "HTTP Method"
|
||||||
@@ -1043,7 +1043,7 @@ msgstr "Istruzioni di configurazione manuale"
|
|||||||
#. Chart select field. Please try to keep this short.
|
#. Chart select field. Please try to keep this short.
|
||||||
#: src/components/routes/system/chart-card.tsx
|
#: src/components/routes/system/chart-card.tsx
|
||||||
msgid "Max 1 min"
|
msgid "Max 1 min"
|
||||||
msgstr ""
|
msgstr "Max 1 min"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
@@ -1109,7 +1109,7 @@ msgstr "Unità rete"
|
|||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "No"
|
msgid "No"
|
||||||
msgstr ""
|
msgstr "No"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1196,7 +1196,7 @@ msgstr "Pagine / Impostazioni"
|
|||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr "Password"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Password must be at least 8 characters."
|
msgid "Password must be at least 8 characters."
|
||||||
@@ -1384,7 +1384,7 @@ msgstr "Riprendi"
|
|||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgctxt "Root disk label"
|
msgctxt "Root disk label"
|
||||||
msgid "Root"
|
msgid "Root"
|
||||||
msgstr ""
|
msgstr "Root"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
@@ -1615,11 +1615,11 @@ msgstr "Temperature dei sensori di sistema"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test <0>URL</0>"
|
msgid "Test <0>URL</0>"
|
||||||
msgstr ""
|
msgstr "Test <0>URL</0>"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Test heartbeat"
|
msgid "Test heartbeat"
|
||||||
msgstr ""
|
msgstr "Test Heartbeat"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test notification sent"
|
msgid "Test notification sent"
|
||||||
@@ -1665,7 +1665,7 @@ msgstr "Attiva/disattiva tema"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr ""
|
msgstr "Token"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Sì"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Le impostazioni utente sono state aggiornate."
|
msgstr "Le impostazioni utente sono state aggiornate."
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: no\n"
|
"Language: no\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-03-31 07:42\n"
|
"PO-Revision-Date: 2026-04-26 23:25\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Norwegian\n"
|
"Language-Team: Norwegian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -57,7 +57,7 @@ msgstr "1 time"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "1 min"
|
msgid "1 min"
|
||||||
msgstr ""
|
msgstr "1 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "1 minute"
|
msgid "1 minute"
|
||||||
@@ -74,7 +74,7 @@ msgstr "12 timer"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "15 min"
|
msgid "15 min"
|
||||||
msgstr ""
|
msgstr "15 min"
|
||||||
|
|
||||||
#: src/lib/utils.ts
|
#: src/lib/utils.ts
|
||||||
msgid "24 hours"
|
msgid "24 hours"
|
||||||
@@ -87,7 +87,7 @@ msgstr "30 dager"
|
|||||||
#. Load average
|
#. Load average
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "5 min"
|
msgid "5 min"
|
||||||
msgstr ""
|
msgstr "5 min"
|
||||||
|
|
||||||
#. Table column
|
#. Table column
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
@@ -137,7 +137,7 @@ msgstr "Juster bredden på hovedlayouten"
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/navbar.tsx
|
#: src/components/navbar.tsx
|
||||||
msgid "Admin"
|
msgid "Admin"
|
||||||
msgstr ""
|
msgstr "Admin"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "After"
|
msgid "After"
|
||||||
@@ -149,7 +149,7 @@ msgstr "Etter å ha angitt miljøvariablene, start Beszel-huben på nytt for at
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Agent"
|
msgid "Agent"
|
||||||
msgstr ""
|
msgstr "Agent"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
@@ -289,7 +289,7 @@ msgstr "Binær"
|
|||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bits (Kbps, Mbps, Gbps)"
|
msgid "Bits (Kbps, Mbps, Gbps)"
|
||||||
msgstr ""
|
msgstr "Bits (Kbps, Mbps, Gbps)"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Boot state"
|
msgid "Boot state"
|
||||||
@@ -298,7 +298,7 @@ msgstr "Oppstartstilstand"
|
|||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Bytes (KB/s, MB/s, GB/s)"
|
msgid "Bytes (KB/s, MB/s, GB/s)"
|
||||||
msgstr ""
|
msgstr "Bytes (KB/s, MB/s, GB/s)"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/memory-charts.tsx
|
#: src/components/routes/system/charts/memory-charts.tsx
|
||||||
msgid "Cache / Buffers"
|
msgid "Cache / Buffers"
|
||||||
@@ -336,7 +336,7 @@ msgstr "Advarsel - potensielt tap av data"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Celsius (°C)"
|
msgid "Celsius (°C)"
|
||||||
msgstr ""
|
msgstr "Celsius (°C)"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Change display units for metrics."
|
msgid "Change display units for metrics."
|
||||||
@@ -361,7 +361,7 @@ msgstr "Diagraminnstillinger"
|
|||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgid "Chart width"
|
msgid "Chart width"
|
||||||
msgstr "Grafbredde"
|
msgstr "Diagrambredde"
|
||||||
|
|
||||||
#: src/components/login/forgot-pass-form.tsx
|
#: src/components/login/forgot-pass-form.tsx
|
||||||
msgid "Check {email} for a reset link."
|
msgid "Check {email} for a reset link."
|
||||||
@@ -496,7 +496,7 @@ msgstr "Kjerne"
|
|||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr ""
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Cores"
|
msgid "CPU Cores"
|
||||||
@@ -601,11 +601,11 @@ msgstr "Lader ut"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Disk"
|
msgid "Disk"
|
||||||
msgstr ""
|
msgstr "Disk"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk I/O"
|
msgid "Disk I/O"
|
||||||
msgstr ""
|
msgstr "Disk I/O"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Disk unit"
|
msgid "Disk unit"
|
||||||
@@ -715,7 +715,7 @@ msgstr "Skriv inn ditt engangspassord."
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Ephemeral"
|
msgid "Ephemeral"
|
||||||
msgstr "Flyktig"
|
msgstr "Midlertidig"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
@@ -772,7 +772,7 @@ msgstr "Eksporter din nåværende systemkonfigurasjon"
|
|||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Fahrenheit (°F)"
|
msgid "Fahrenheit (°F)"
|
||||||
msgstr ""
|
msgstr "Fahrenheit (°F)"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Failed"
|
msgid "Failed"
|
||||||
@@ -794,7 +794,7 @@ msgstr "Kunne ikke lagre innstillingene"
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Failed to send heartbeat"
|
msgid "Failed to send heartbeat"
|
||||||
msgstr "Kunne ikke sende heartbeat"
|
msgstr "Kunne ikke sende hjerteslag"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Failed to send test notification"
|
msgid "Failed to send test notification"
|
||||||
@@ -816,7 +816,7 @@ msgstr "Mislyktes: {0}"
|
|||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
msgid "Filter..."
|
msgid "Filter..."
|
||||||
msgstr ""
|
msgstr "Filter..."
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Fingerprint"
|
msgid "Fingerprint"
|
||||||
@@ -854,11 +854,11 @@ msgstr "Generelt"
|
|||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Global"
|
msgid "Global"
|
||||||
msgstr ""
|
msgstr "Global"
|
||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "GPU"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -887,11 +887,11 @@ msgstr "Hjerteslag"
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
msgstr "Heartbeat-overvåking"
|
msgstr "Hjerteslagsovervåking"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat sent successfully"
|
msgid "Heartbeat sent successfully"
|
||||||
msgstr "Heartbeat sendt"
|
msgstr "Hjerteslag sendt vellykket"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
@@ -938,7 +938,7 @@ msgstr "Dersom du har mistet passordet til admin-kontoen kan du nullstille det m
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Docker image"
|
msgctxt "Docker image"
|
||||||
msgid "Image"
|
msgid "Image"
|
||||||
msgstr ""
|
msgstr "Image"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Inactive"
|
msgid "Inactive"
|
||||||
@@ -1216,7 +1216,7 @@ msgstr "Fortid"
|
|||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Pause"
|
msgid "Pause"
|
||||||
msgstr ""
|
msgstr "Pause"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Paused"
|
msgid "Paused"
|
||||||
@@ -1228,7 +1228,7 @@ msgstr "Pauset ({pausedSystemsLength})"
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Payload format"
|
msgid "Payload format"
|
||||||
msgstr "Nyttelastformat"
|
msgstr "Lastformat"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
@@ -1245,7 +1245,7 @@ msgstr "Prosentandel av tid brukt i hver tilstand"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Permanent"
|
msgid "Permanent"
|
||||||
msgstr ""
|
msgstr "Permanent"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Persistence"
|
msgid "Persistence"
|
||||||
@@ -1286,7 +1286,7 @@ msgstr "Vennligst logg inn på kontoen din"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr ""
|
msgstr "Port"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
@@ -1457,7 +1457,7 @@ msgstr "Velg {foo}"
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Send a single heartbeat ping to verify your endpoint is working."
|
msgid "Send a single heartbeat ping to verify your endpoint is working."
|
||||||
msgstr "Send en enkelt heartbeat-ping for å bekrefte at endepunktet fungerer."
|
msgstr "Send en enkelt hjerteslag-ping for å verifisere at endepunktet ditt fungerer."
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
|
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
|
||||||
@@ -1465,7 +1465,7 @@ msgstr "Send periodiske utgående pinger til en ekstern overvåkingstjeneste sli
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Send test heartbeat"
|
msgid "Send test heartbeat"
|
||||||
msgstr "Send test-heartbeat"
|
msgstr "Send test-hjerteslag"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/network-charts.tsx
|
#: src/components/routes/system/charts/network-charts.tsx
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
@@ -1490,7 +1490,7 @@ msgstr "Angi prosentvise terskler for målerfarger."
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
|
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
|
||||||
msgstr "Angi følgende miljøvariabler på Beszel-huben din for å aktivere heartbeat-overvåking:"
|
msgstr "Sett følgende miljøvariabler på Beszel-huben din for å aktivere hjerteslagsovervåking:"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
@@ -1536,7 +1536,7 @@ msgstr "Tilstand"
|
|||||||
#: src/components/systems-table/systems-table.tsx
|
#: src/components/systems-table/systems-table.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Status"
|
msgid "Status"
|
||||||
msgstr ""
|
msgstr "Status"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
msgid "Sub State"
|
msgid "Sub State"
|
||||||
@@ -1563,7 +1563,7 @@ msgstr "Swap-bruk"
|
|||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "System"
|
msgid "System"
|
||||||
msgstr ""
|
msgstr "System"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/load-average-chart.tsx
|
#: src/components/routes/system/charts/load-average-chart.tsx
|
||||||
msgid "System load averages over time"
|
msgid "System load averages over time"
|
||||||
@@ -1598,7 +1598,7 @@ msgstr "Oppgaver"
|
|||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Temp"
|
msgid "Temp"
|
||||||
msgstr ""
|
msgstr "Temp"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/sensor-charts.tsx
|
#: src/components/routes/system/charts/sensor-charts.tsx
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
@@ -1615,11 +1615,11 @@ msgstr "Temperaturer på system-sensorer"
|
|||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test <0>URL</0>"
|
msgid "Test <0>URL</0>"
|
||||||
msgstr ""
|
msgstr "Test <0>URL</0>"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Test heartbeat"
|
msgid "Test heartbeat"
|
||||||
msgstr "Test-heartbeat"
|
msgstr "Test-hjerteslag"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Test notification sent"
|
msgid "Test notification sent"
|
||||||
@@ -1665,7 +1665,7 @@ msgstr "Tema av/på"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr ""
|
msgstr "Token"
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
@@ -1697,7 +1697,7 @@ msgstr "Totalt sendt data for hvert grensesnitt"
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O"
|
msgctxt "Disk I/O"
|
||||||
msgid "Total time spent on read/write (can exceed 100%)"
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
msgstr ""
|
msgstr "Total tid brukt på lesing/skriving (kan overstige 100 %)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1760,7 +1760,7 @@ msgstr "Slår inn når forbruk av hvilken som helst disk overstiger en grensever
|
|||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Type"
|
msgid "Type"
|
||||||
msgstr ""
|
msgstr "Type"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Unit file"
|
msgid "Unit file"
|
||||||
@@ -1774,7 +1774,7 @@ msgstr "Enhetspreferanser"
|
|||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Universal token"
|
msgid "Universal token"
|
||||||
msgstr ""
|
msgstr "Universal token"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
@@ -1898,7 +1898,7 @@ msgstr "Når aktivert, tillater denne tokenen agenter å registrere seg selv ute
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
|
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
|
||||||
msgstr "Ved bruk av POST inkluderer hver heartbeat en JSON-nyttelast med systemstatussammendrag, liste over nede systemer og utløste varsler."
|
msgstr "Ved bruk av POST inkluderer hver hjerteslag en JSON-nyttelast med systemstatussammendrag, liste over nede systemer og utløste varsler."
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Ja"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Dine brukerinnstillinger har blitt oppdatert."
|
msgstr "Dine brukerinnstillinger har blitt oppdatert."
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: ru\n"
|
"Language: ru\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-03-27 22:12\n"
|
"PO-Revision-Date: 2026-04-28 05:14\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Russian\n"
|
"Language-Team: Russian\n"
|
||||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||||
@@ -211,7 +211,7 @@ msgstr "Среднее превышает <0>{value}{0}</0>"
|
|||||||
|
|
||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgid "Average number of I/O operations waiting to be serviced"
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
msgstr "Среднее количество операций ввода-вывода, ожидающих обслуживания"
|
msgstr "Среднее количество операций ввода/вывода, ожидающих обслуживания"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
@@ -858,7 +858,7 @@ msgstr "Глобально"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "GPU"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -883,7 +883,7 @@ msgstr "Здоровье"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Heartbeat"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -914,17 +914,17 @@ msgstr "HTTP-метод: POST, GET или HEAD (по умолчанию: POST)"
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O average operation time (iostat await)"
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
msgid "I/O Await"
|
msgid "I/O Await"
|
||||||
msgstr "Ожидание ввода-вывода"
|
msgstr "Ожидание ввода/вывода"
|
||||||
|
|
||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O total time spent on read/write"
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
msgid "I/O Time"
|
msgid "I/O Time"
|
||||||
msgstr "Время ввода-вывода"
|
msgstr "Время ввода/вывода"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgctxt "Percent of time the disk is busy with I/O"
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
msgid "I/O Utilization"
|
msgid "I/O Utilization"
|
||||||
msgstr "Использование ввода-вывода"
|
msgstr "Использование ввода/вывода"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
@@ -1237,7 +1237,7 @@ msgstr "Среднее использование на ядро"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Percent of time the disk is busy with I/O"
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
msgstr "Процент времени, в течение которого диск занят вводом-выводом"
|
msgstr "Процент времени, в течение которого диск занят вводом/выводом"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
@@ -1697,7 +1697,7 @@ msgstr "Общий объем отправленных данных для ка
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O"
|
msgctxt "Disk I/O"
|
||||||
msgid "Total time spent on read/write (can exceed 100%)"
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
msgstr ""
|
msgstr "Общее время, потраченное на чтение/запись (может превышать 100%)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Да"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Ваши настройки пользователя были обновлены."
|
msgstr "Ваши настройки пользователя были обновлены."
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: sr\n"
|
"Language: sr\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
"PO-Revision-Date: 2026-04-22 14:14\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Serbian (Cyrillic)\n"
|
"Language-Team: Serbian (Cyrillic)\n"
|
||||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||||
@@ -858,7 +858,7 @@ msgstr "Глобално"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "ГПЈ"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -1384,7 +1384,7 @@ msgstr "Настави"
|
|||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgctxt "Root disk label"
|
msgctxt "Root disk label"
|
||||||
msgid "Root"
|
msgid "Root"
|
||||||
msgstr ""
|
msgstr "Root"
|
||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Rotate token"
|
msgid "Rotate token"
|
||||||
@@ -1697,7 +1697,7 @@ msgstr "Укупни подаци poslati за сваки интерфејс"
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O"
|
msgctxt "Disk I/O"
|
||||||
msgid "Total time spent on read/write (can exceed 100%)"
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
msgstr ""
|
msgstr "Укупно време проведено на читању/писању (може бити веће од 100%)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Да"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Ваша корисничка подешавања су ажурирана."
|
msgstr "Ваша корисничка подешавања су ажурирана."
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: tr\n"
|
"Language: tr\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
"PO-Revision-Date: 2026-05-30 22:33\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Turkish\n"
|
"Language-Team: Turkish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -858,7 +858,7 @@ msgstr "Genel"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "Ekran Kartı"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -883,7 +883,7 @@ msgstr "Sağlık"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Sağlık Sinyali"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -1074,7 +1074,7 @@ msgstr "Docker konteynerlerinin bellek kullanımı"
|
|||||||
#. Device model
|
#. Device model
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
msgid "Model"
|
msgid "Model"
|
||||||
msgstr ""
|
msgstr "Model"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
@@ -1286,7 +1286,7 @@ msgstr "Lütfen hesabınıza giriş yapın"
|
|||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
msgid "Port"
|
msgid "Port"
|
||||||
msgstr ""
|
msgstr "Port"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
@@ -1697,7 +1697,7 @@ msgstr "Her arayüz için gönderilen toplam veri"
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O"
|
msgctxt "Disk I/O"
|
||||||
msgid "Total time spent on read/write (can exceed 100%)"
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
msgstr ""
|
msgstr "Okuma/yazma işlemlerinde harcanan toplam süre (100%’ü aşabilir)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Evet"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Kullanıcı ayarlarınız güncellendi."
|
msgstr "Kullanıcı ayarlarınız güncellendi."
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: uk\n"
|
"Language: uk\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
"PO-Revision-Date: 2026-05-08 11:22\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Ukrainian\n"
|
"Language-Team: Ukrainian\n"
|
||||||
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
"Plural-Forms: nplurals=4; plural=((n%10==1 && n%100!=11) ? 0 : ((n%10 >= 2 && n%10 <=4 && (n%100 < 12 || n%100 > 14)) ? 1 : ((n%10 == 0 || (n%10 >= 5 && n%10 <=9)) || (n%100 >= 11 && n%100 <= 14)) ? 2 : 3));\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr "{0} доступно"
|
msgstr "{0} Доступно"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -858,7 +858,7 @@ msgstr "Глобально"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "Графічний процесор"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -883,7 +883,7 @@ msgstr "Здоров'я"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Heartbeat"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -1697,7 +1697,7 @@ msgstr "Загальний обсяг відправлених даних для
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O"
|
msgctxt "Disk I/O"
|
||||||
msgid "Total time spent on read/write (can exceed 100%)"
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
msgstr ""
|
msgstr "Загальний час, витрачений на читання/запис (може перевищувати 100%)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "Так"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "Ваші налаштування користувача були оновлені."
|
msgstr "Ваші налаштування користувача були оновлені."
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: zh\n"
|
"Language: zh\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
"PO-Revision-Date: 2026-05-07 01:51\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Chinese Simplified\n"
|
"Language-Team: Chinese Simplified\n"
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
@@ -211,7 +211,7 @@ msgstr "平均值超过<0>{value}{0}</0>"
|
|||||||
|
|
||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgid "Average number of I/O operations waiting to be serviced"
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
msgstr "等待服务的平均 I/O 操作数"
|
msgstr "等待处理的 I/O 操作平均数量"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
@@ -272,7 +272,7 @@ msgstr "之前"
|
|||||||
#. placeholder {2}: alert.min
|
#. placeholder {2}: alert.min
|
||||||
#: src/components/active-alerts.tsx
|
#: src/components/active-alerts.tsx
|
||||||
msgid "Below {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
msgid "Below {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
|
||||||
msgstr "在过去的{2, plural, one {# 分钟} other {# 分钟}}中低于{0}{1}"
|
msgstr "在过去{2, plural, one {# 分钟} other {# 分钟}}中低于{0}{1}"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
|
||||||
@@ -496,7 +496,7 @@ msgstr "核心"
|
|||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr ""
|
msgstr "CPU"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Cores"
|
msgid "CPU Cores"
|
||||||
@@ -715,7 +715,7 @@ msgstr "输入您的一次性密码。"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Ephemeral"
|
msgid "Ephemeral"
|
||||||
msgstr "临时"
|
msgstr "暂时"
|
||||||
|
|
||||||
#: src/components/login/auth-form.tsx
|
#: src/components/login/auth-form.tsx
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
@@ -756,7 +756,7 @@ msgstr "退出活动状态"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Expires after one hour or on hub restart."
|
msgid "Expires after one hour or on hub restart."
|
||||||
msgstr "一小时后或重新启动集线器时过期。"
|
msgstr "将在一小时后,或Hub重启时失效。"
|
||||||
|
|
||||||
#: src/components/routes/settings/alerts-history-data-table.tsx
|
#: src/components/routes/settings/alerts-history-data-table.tsx
|
||||||
msgid "Export"
|
msgid "Export"
|
||||||
@@ -794,7 +794,7 @@ msgstr "保存设置失败"
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Failed to send heartbeat"
|
msgid "Failed to send heartbeat"
|
||||||
msgstr "发送 heartbeat 失败"
|
msgstr "心跳发送失败"
|
||||||
|
|
||||||
#: src/components/routes/settings/notifications.tsx
|
#: src/components/routes/settings/notifications.tsx
|
||||||
msgid "Failed to send test notification"
|
msgid "Failed to send test notification"
|
||||||
@@ -858,7 +858,7 @@ msgstr "全局"
|
|||||||
|
|
||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgid "GPU"
|
msgid "GPU"
|
||||||
msgstr ""
|
msgstr "显卡"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "GPU Engines"
|
msgid "GPU Engines"
|
||||||
@@ -887,11 +887,11 @@ msgstr "心跳"
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
msgstr "Heartbeat 监控"
|
msgstr "心跳监控"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat sent successfully"
|
msgid "Heartbeat sent successfully"
|
||||||
msgstr "Heartbeat 发送成功"
|
msgstr "心跳发送成功"
|
||||||
|
|
||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
@@ -1237,7 +1237,7 @@ msgstr "每个核心的平均利用率"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Percent of time the disk is busy with I/O"
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
msgstr "磁盘忙于 I/O 操作的时间百分比"
|
msgstr "磁盘用于 I/O 操作的繁忙时间百分比"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
@@ -1421,7 +1421,7 @@ msgstr "保存设置"
|
|||||||
|
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Saved in the database and does not expire until you disable it."
|
msgid "Saved in the database and does not expire until you disable it."
|
||||||
msgstr "保存在数据库中,在您禁用之前不会过期。"
|
msgstr "保存在数据库中的数据,在您禁用之前不会过期。"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Schedule"
|
msgid "Schedule"
|
||||||
@@ -1457,15 +1457,15 @@ msgstr "选择 {foo}"
|
|||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Send a single heartbeat ping to verify your endpoint is working."
|
msgid "Send a single heartbeat ping to verify your endpoint is working."
|
||||||
msgstr "发送单个 heartbeat ping 以验证您的端点是否正常工作。"
|
msgstr "发送单个 心跳ping 以验证您的端点是否正常工作。"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
|
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
|
||||||
msgstr "定期向外部监控服务发送出站 ping,以便您在不将 Beszel 暴露于互联网的情况下进行监控。"
|
msgstr "定期向外部监控服务发送外发探测请求,这样您便可以在不将其暴露于互联网的情况下对 Beszel 进行监控。"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Send test heartbeat"
|
msgid "Send test heartbeat"
|
||||||
msgstr "发送测试 heartbeat"
|
msgstr "发送测试 心跳"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/network-charts.tsx
|
#: src/components/routes/system/charts/network-charts.tsx
|
||||||
msgid "Sent"
|
msgid "Sent"
|
||||||
@@ -1697,7 +1697,7 @@ msgstr "每个接口的总发送数据量"
|
|||||||
#: src/components/routes/system/disk-io-sheet.tsx
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
msgctxt "Disk I/O"
|
msgctxt "Disk I/O"
|
||||||
msgid "Total time spent on read/write (can exceed 100%)"
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
msgstr ""
|
msgstr "读写操作总耗时(可超过 100%)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
@@ -1931,3 +1931,4 @@ msgstr "是"
|
|||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Your user settings have been updated."
|
msgid "Your user settings have been updated."
|
||||||
msgstr "您的用户设置已更新。"
|
msgstr "您的用户设置已更新。"
|
||||||
|
|
||||||
|
|||||||
@@ -94,18 +94,6 @@ const Layout = () => {
|
|||||||
document.documentElement.dir = direction
|
document.documentElement.dir = direction
|
||||||
}, [direction])
|
}, [direction])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// refresh auth if not authenticated (required for trusted auth header)
|
|
||||||
if (!authenticated) {
|
|
||||||
pb.collection("users")
|
|
||||||
.authRefresh()
|
|
||||||
.then((res) => {
|
|
||||||
pb.authStore.save(res.token, res.record)
|
|
||||||
$authenticated.set(!!pb.authStore.isValid)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DirectionProvider dir={direction}>
|
<DirectionProvider dir={direction}>
|
||||||
{!authenticated ? (
|
{!authenticated ? (
|
||||||
|
|||||||
1
internal/site/src/types.d.ts
vendored
1
internal/site/src/types.d.ts
vendored
@@ -7,6 +7,7 @@ declare global {
|
|||||||
BASE_PATH: string
|
BASE_PATH: string
|
||||||
HUB_VERSION: string
|
HUB_VERSION: string
|
||||||
HUB_URL: string
|
HUB_URL: string
|
||||||
|
OAUTH_DISABLE_POPUP: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"baseUrl": ".",
|
// "baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user