Compare commits

..

36 Commits

Author SHA1 Message Date
henrygd
3b9910351d release 0.10.1 2025-03-06 05:40:38 -05:00
henrygd
f397ab0797 fix: improve error logging for temperature sensor retrieval 2025-03-06 05:38:49 -05:00
henrygd
b1fc715ec9 fix: prevent 404 on initial startup by moving h.initialize after hooks
- I don't know why this works. Need to look further into it tomorrow :)
2025-03-06 05:38:33 -05:00
henrygd
d25c7c58c1 fix: SYS_SENSORS context error (#643) 2025-03-06 05:36:20 -05:00
henrygd
a6daa70010 chore: bump version to 0.10.0 🎉 2025-03-06 03:11:22 -05:00
henrygd
d722e4712c i18n: update language files 2025-03-06 03:10:34 -05:00
henrygd
1d61ad5d7c deps: update pocketbase 2025-03-06 03:10:01 -05:00
henrygd
28589455bf feat: keep chart time when navigating with arrow keys 2025-03-06 02:23:30 -05:00
henrygd
dd21c18939 feat: add SHARE_ALL_SYSTEMS env var 2025-03-06 01:28:36 -05:00
henrygd
fd79bc3341 i18n: update language files and fix strings for new lingui version 2025-03-06 00:58:29 -05:00
henrygd
7edcf8db85 i18n: new Spanish translations (by Elkin Torres on crowdin) 2025-03-06 00:21:32 -05:00
Roy W. Andersen
245a047062 i18n: new Norwegian translations 2025-03-06 00:19:58 -05:00
stanol
520b52e532 i18n: new Ukrainian translations 2025-03-06 00:19:26 -05:00
Stepan
c421ffac70 i18n: new Russian translations 2025-03-06 00:18:49 -05:00
henrygd
6767392ea8 refactor: update some types in docker.go 2025-03-05 23:40:23 -05:00
henrygd
25b73bfb85 fix: make sure system alerts are checked after records are committed 2025-03-05 23:39:01 -05:00
henrygd
5fbc0de07f refactor: rename address flag and environment variable to 'listen' 2025-03-05 23:37:51 -05:00
henrygd
c8130a10d4 fix: allow logout if auto login if enabled 2025-03-05 23:36:57 -05:00
henrygd
0619eabec2 feat: add keyboard navigation for systems 2025-03-05 23:35:46 -05:00
henrygd
5b4d5c648e chore: update migrations 2025-03-05 23:34:15 -05:00
henrygd
0443a85015 fix: correct typo in Docker stats collection variable name 2025-03-04 17:39:49 -05:00
henrygd
c4d8deb986 feat: agent data cache to support connections to multiple hubs (#341) 2025-03-04 16:25:45 -05:00
henrygd
681286eb4f fix: add User-Agent to resolve Docker Desktop bug (#513, #603)
- also added body closure I forgot earlier whoops
2025-03-04 01:56:22 -05:00
henrygd
99cdb196ca feat: persist selected tab in system dialog (#602) 2025-03-04 01:09:48 -05:00
henrygd
31431fd211 refactor: improve GPU data parsing
- Use byte-based regex matching instead of string-based matching
- Increase buffer size for GPU data
- Switch to `bufio.Scanner`
2025-03-04 00:15:10 -05:00
henrygd
9e56f4611f refactor: restructure hub initialization and startup process
- Separated hub initialization logic into distinct methods
- Move command specific things to cmd/hub
- Add compatibility with new systems package
2025-03-03 23:54:25 -05:00
henrygd
a1f6eeb9eb refactor: alerts package compatiblity with new systems package 2025-03-03 23:52:27 -05:00
henrygd
f8a1d9fc5d refactor: optimize system updates and create systems package
- Created SystemManager to handle system lifecycle and events
- Created tests for system management operations
- Added test helpers for creating and managing test systems
- Introduced optional port configuration in system config
2025-03-03 23:50:19 -05:00
henrygd
d81db6e319 refactor: optimize record management and deletion logic 2025-03-03 23:44:50 -05:00
henrygd
17a163de26 feat: allow searching by status in systems table 2025-03-03 23:39:50 -05:00
henrygd
85db31a8cd chore: add Makefile test target for running Go tests 2025-03-03 23:38:41 -05:00
henrygd
327db38953 chore: update go dependencies 2025-03-03 23:38:01 -05:00
henrygd
0413368762 chore: update js dependencies 2025-03-03 23:37:43 -05:00
henrygd
db73928604 chore: update gitignore 2025-03-03 23:36:26 -05:00
henrygd
add1b27346 remove unreleased migration 2025-03-03 23:35:18 -05:00
henrygd
2ef1fe6b2a systemd: remove ProtectKernelTunables=yes (#176)
https://github.com/henrygd/beszel/issues/176#issuecomment-2691192334
2025-02-28 14:28:00 -05:00
68 changed files with 10953 additions and 10684 deletions

3
.gitignore vendored
View File

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

View File

@@ -14,6 +14,10 @@ clean:
lint:
golangci-lint run
test: export GOEXPERIMENT=synctest
test:
go test -tags=testing ./...
tidy:
go mod tidy

View File

@@ -14,14 +14,14 @@ import (
// cli options
type cmdOptions struct {
key string // key is the public key(s) for SSH authentication.
addr string // addr is the address or port to listen on.
key string // key is the public key(s) for SSH authentication.
listen string // listen is the address or port to listen on.
}
// parseFlags parses the command line flags and populates the config struct.
func (opts *cmdOptions) parseFlags() {
flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication")
flag.StringVar(&opts.addr, "addr", "", "Address or port to listen on")
flag.StringVar(&opts.listen, "listen", "", "Address or port to listen on")
flag.Usage = func() {
fmt.Printf("Usage: %s [options] [subcommand]\n", os.Args[0])
@@ -82,11 +82,11 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
// getAddress gets the address to listen on from the command line flag, environment variable, or default value.
func (opts *cmdOptions) getAddress() string {
// Try command line flag first
if opts.addr != "" {
return opts.addr
if opts.listen != "" {
return opts.listen
}
// Try environment variables
if addr, ok := agent.GetEnv("ADDR"); ok && addr != "" {
if addr, ok := agent.GetEnv("LISTEN"); ok && addr != "" {
return addr
}
// Legacy PORT environment variable support
@@ -101,7 +101,7 @@ func (opts *cmdOptions) getNetwork() string {
if network, _ := agent.GetEnv("NETWORK"); network != "" {
return network
}
if strings.HasPrefix(opts.addr, "/") {
if strings.HasPrefix(opts.listen, "/") {
return "unix"
}
return "tcp"
@@ -117,7 +117,7 @@ func main() {
flag.Parse()
opts.addr = opts.getAddress()
opts.listen = opts.getAddress()
var serverConfig agent.ServerOptions
var err error
@@ -126,7 +126,7 @@ func main() {
log.Fatal("Failed to load public keys:", err)
}
serverConfig.Addr = opts.addr
serverConfig.Addr = opts.listen
serverConfig.Network = opts.getNetwork()
agent := agent.NewAgent()

View File

@@ -27,22 +27,22 @@ func TestGetAddress(t *testing.T) {
{
name: "use address from flag",
opts: cmdOptions{
addr: "8080",
listen: "8080",
},
expected: "8080",
},
{
name: "use unix socket from flag",
opts: cmdOptions{
addr: "/tmp/beszel.sock",
listen: "/tmp/beszel.sock",
},
expected: "/tmp/beszel.sock",
},
{
name: "use ADDR env var",
name: "use LISTEN env var",
opts: cmdOptions{},
envVars: map[string]string{
"ADDR": "1.2.3.4:9090",
"LISTEN": "1.2.3.4:9090",
},
expected: "1.2.3.4:9090",
},
@@ -57,21 +57,21 @@ func TestGetAddress(t *testing.T) {
{
name: "use unix socket from env var",
opts: cmdOptions{
addr: "",
listen: "",
},
envVars: map[string]string{
"ADDR": "/tmp/beszel.sock",
"LISTEN": "/tmp/beszel.sock",
},
expected: "/tmp/beszel.sock",
},
{
name: "flag takes precedence over env vars",
opts: cmdOptions{
addr: ":8080",
listen: ":8080",
},
envVars: map[string]string{
"ADDR": ":9090",
"PORT": "7070",
"LISTEN": ":9090",
"PORT": "7070",
},
expected: ":8080",
},
@@ -201,27 +201,27 @@ func TestGetNetwork(t *testing.T) {
},
{
name: "only port",
opts: cmdOptions{addr: "8080"},
opts: cmdOptions{listen: "8080"},
expected: "tcp",
},
{
name: "ipv4 address",
opts: cmdOptions{addr: "1.2.3.4:8080"},
opts: cmdOptions{listen: "1.2.3.4:8080"},
expected: "tcp",
},
{
name: "ipv6 address",
opts: cmdOptions{addr: "[2001:db8::1]:8080"},
opts: cmdOptions{listen: "[2001:db8::1]:8080"},
expected: "tcp",
},
{
name: "unix network",
opts: cmdOptions{addr: "/tmp/beszel.sock"},
opts: cmdOptions{listen: "/tmp/beszel.sock"},
expected: "unix",
},
{
name: "env var network",
opts: cmdOptions{addr: ":8080"},
opts: cmdOptions{listen: ":8080"},
envVars: map[string]string{"NETWORK": "tcp4"},
expected: "tcp4",
},
@@ -256,32 +256,32 @@ func TestParseFlags(t *testing.T) {
name: "no flags",
args: []string{"cmd"},
expected: cmdOptions{
key: "",
addr: "",
key: "",
listen: "",
},
},
{
name: "key flag only",
args: []string{"cmd", "-key", "testkey"},
expected: cmdOptions{
key: "testkey",
addr: "",
key: "testkey",
listen: "",
},
},
{
name: "addr flag only",
args: []string{"cmd", "-addr", ":8080"},
args: []string{"cmd", "-listen", ":8080"},
expected: cmdOptions{
key: "",
addr: ":8080",
key: "",
listen: ":8080",
},
},
{
name: "both flags",
args: []string{"cmd", "-key", "testkey", "-addr", ":8080"},
args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
expected: cmdOptions{
key: "testkey",
addr: ":8080",
key: "testkey",
listen: ":8080",
},
},
}

View File

@@ -1,10 +1,46 @@
package main
import (
"beszel"
"beszel/internal/hub"
_ "beszel/migrations"
"os"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/spf13/cobra"
)
func main() {
hub.NewHub().Run()
baseApp := getBaseApp()
h := hub.NewHub(baseApp)
h.BootstrapHub()
h.Start()
}
// getBaseApp creates a new PocketBase app with the default config
func getBaseApp() *pocketbase.PocketBase {
isDev := os.Getenv("ENV") == "dev"
baseApp := pocketbase.NewWithConfig(pocketbase.Config{
DefaultDataDir: beszel.AppName + "_data",
DefaultDev: isDev,
})
baseApp.RootCmd.Version = beszel.Version
baseApp.RootCmd.Use = beszel.AppName
baseApp.RootCmd.Short = ""
// add update command
baseApp.RootCmd.AddCommand(&cobra.Command{
Use: "update",
Short: "Update " + beszel.AppName + " to the latest version",
Run: hub.Update,
})
// enable auto creation of migration files when making collection changes in the Admin UI
migratecmd.MustRegister(baseApp, baseApp.RootCmd, migratecmd.Config{
Automigrate: isDev,
Dir: "../../migrations",
})
return baseApp
}

View File

@@ -8,39 +8,39 @@ require (
github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.5
github.com/pocketbase/dbx v1.11.0
github.com/pocketbase/pocketbase v0.25.0
github.com/pocketbase/pocketbase v0.25.9
github.com/rhysd/go-github-selfupdate v1.2.3
github.com/shirou/gopsutil/v4 v4.25.1
github.com/shirou/gopsutil/v4 v4.25.2
github.com/spf13/cast v1.7.1
github.com/spf13/cobra v1.8.1
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.32.0
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c
golang.org/x/crypto v0.35.0
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.1 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.6 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 // indirect
github.com/aws/smithy-go v1.22.2 // indirect
github.com/aws/aws-sdk-go-v2 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 // indirect
github.com/aws/smithy-go v1.22.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
@@ -59,7 +59,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
@@ -75,19 +75,18 @@ require (
go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.40.0 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/oauth2 v0.26.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/term v0.29.0 // indirect
golang.org/x/text v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.220.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 // indirect
google.golang.org/api v0.223.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e // indirect
google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
modernc.org/libc v1.55.3 // indirect
google.golang.org/protobuf v1.36.5 // indirect
modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // indirect
modernc.org/sqlite v1.34.5 // indirect
modernc.org/sqlite v1.35.0 // indirect
)

View File

@@ -1,8 +1,8 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0=
cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM=
cloud.google.com/go/auth v0.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.15.0/go.mod h1:WJDGqZ1o9E9wKIL+IwStfyn/+s59zl4Bi+1KQNVXLZ8=
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
@@ -22,44 +22,44 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/aws/aws-sdk-go v1.55.5 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
github.com/aws/aws-sdk-go-v2 v1.36.1 h1:iTDl5U6oAhkNPba0e1t1hrwAo02ZMqbrGq4k5JBWM5E=
github.com/aws/aws-sdk-go-v2 v1.36.1/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg=
github.com/aws/aws-sdk-go-v2/config v1.29.6 h1:fqgqEKK5HaZVWLQoLiC9Q+xDlSp+1LYidp6ybGE2OGg=
github.com/aws/aws-sdk-go-v2/config v1.29.6/go.mod h1:Ft+WLODzDQmCTHDvqAH1JfC2xxbZ0MxpZAcJqmE1LTQ=
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 h1:9btwmrt//Q6JcSdgJOLI98sdr5p7tssS9yAsGe8aKP4=
github.com/aws/aws-sdk-go-v2/credentials v1.17.59/go.mod h1:NM8fM6ovI3zak23UISdWidyZuI1ghNe2xjzUZAyT+08=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59 h1:5Vsrfdlf9KQP3leGX1dD7VwZq/3HAerEFoXAII4t6zo=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59/go.mod h1:7XTNs3NYApJjkx6A2Fk9qq23qBuBnIU58k3fKC2Fr1I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 h1:BjUcr3X3K0wZPGFg2bxOWW3VPN8rkE3/61zhP+IHviA=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32/go.mod h1:80+OGC/bgzzFFTUmcuwD0lb4YutwQeKLFpmt6hoWapU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 h1:m1GeXHVMJsRsUAqG6HjZWx9dj7F5TR+cF1bjyfYyBd4=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32/go.mod h1:IitoQxGfaKdVLNg0hD8/DXmAqNy0H4K2H2Sf91ti8sI=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 h1:OIHj/nAhVzIXGzbAE+4XmZ8FPvro3THr6NlqErJc3wY=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32/go.mod h1:LiBEsDo34OJXqdDlRGsilhlIiXR7DL+6Cx2f4p1EgzI=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6 h1:cCBJaT7EeEojpJ4s7wTDbhZlHVJOgNHN7iw6qVurGaw=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6/go.mod h1:WYH1ABybY7JK9TITPnk6ZlP7gQB8psI4c9qDmMsnLSA=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 h1:SYVGSFQHlchIcy6e7x12bsrxClCXSP5et8cqVhL8cuw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13/go.mod h1:kizuDaLX37bG5WZaoxGPQR/LNFXpxp0vsUnqfkWXfNE=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 h1:OBsrtam3rk8NfBEq7OLOMm5HtQ9Yyw32X4UQMya/wjw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13/go.mod h1:3U4gFA5pmoCOja7aq4nSaIAGbaOHv2Yl2ug018cmC+Q=
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4 h1:DJYjOvNgC30JAcDCRmtQHoYK4trc7XetDXRTEAReGKA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4/go.mod h1:KuLNrwYJFaC2AVZ+CVVc12k9NyqwgWsoNNHjwqF6QNk=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 h1:/eE3DogBjYlvlbhd2ssWyeuovWunHLxfgw3s/OJa4GQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15/go.mod h1:2PCJYpi7EKeA5SkStAmZlF6fi0uUABuhtF8ILHjGc3Y=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 h1:M/zwXiL2iXUrHputuXgmO94TVNmcenPHxgLXLutodKE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14/go.mod h1:RVwIw3y/IqxC2YEXSIkAzRDdEU1iRabDPaYjpGCbCGQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 h1:TzeR06UCMUq+KA3bDkujxK1GVGy+G8qQN/QVYzGLkQE=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14/go.mod h1:dspXf/oYWGWo6DEvj98wpaTeqt5+DMidZD0A9BYTizc=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/aws/aws-sdk-go-v2 v1.36.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.3/go.mod h1:LLXuLpgzEbD766Z5ECcRmi8AzSwfZItDtmABVkRLGzg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14=
github.com/aws/aws-sdk-go-v2/config v1.29.8 h1:RpwAfYcV2lr/yRc4lWhUM9JRPQqKgKWmou3LV7UfWP4=
github.com/aws/aws-sdk-go-v2/config v1.29.8/go.mod h1:t+G7Fq1OcO8cXTPPXzxQSnj/5Xzdc9jAAD3Xrn9/Mgo=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61 h1:Hd/uX6Wo2iUW1JWII+rmyCD7MMhOe7ALwQXN6sKDd1o=
github.com/aws/aws-sdk-go-v2/credentials v1.17.61/go.mod h1:L7vaLkwHY1qgW0gG1zG0z/X0sQ5tpIY5iI13+j3qI80=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64 h1:RTko0AQ0i1vWXDM97DkuW6zskgOxFxm4RqC0kmBJFkE=
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.64/go.mod h1:ty968MpOa5CoQ/ALWNB8Gmfoehof2nRHDR/DZDPfimE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34 h1:ZK5jHhnrioRkUNOc+hOgQKlUL5JeC3S6JgLxtQ+Rm0Q=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.34/go.mod h1:p4VfIceZokChbA9FzMbRGz5OV+lekcVtHlPKEO0gSZY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34 h1:SZwFm17ZUNNg5Np0ioo/gq8Mn6u9w19Mri8DnJ15Jf0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.34/go.mod h1:dFZsC0BLo346mvKQLWmoJxT+Sjp+qcVR1tRVHQGOH9Q=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2 h1:t/gZFyrijKuSU0elA5kRngP/oU3mc0I+Dvp8HwRE4c0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.6.2/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0 h1:EBm8lXevBWe+kK9VOU/IBeOI189WPRwPUc3LvJK9GOs=
github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0/go.mod h1:4qzsZSzB/KiX2EzDjs9D7A8rI/WGJxZceVJIHqtJjIU=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 h1:2U9sF8nKy7UgyEeLiZTRg6ShBS22z8UnYpV6aRFL0is=
github.com/aws/aws-sdk-go-v2/service/sso v1.25.0/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 h1:wjAdc85cXdQR5uLx5FwWvGIHm4OPJhTyzUHU8craXtE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcPhKvYP5s1xf8/izn0=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.3/go.mod h1:t1ufH5HMublsJYulve2RKmHDC15xu1f26kHCp/HgceI=
github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
@@ -67,7 +67,7 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
@@ -139,8 +139,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
@@ -175,8 +175,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb h1:YU0XAr3+rMpM8fP80KEesn32Qa9qkbquokvuwzWyYuA=
github.com/lufia/plan9stats v0.0.0-20250224150550-a661cff19cfb/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -195,8 +195,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.11.0 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.25.0 h1:/4YQq1hd0muvhzbERyUTVNh88N0BCj5diqK0jtLN6k8=
github.com/pocketbase/pocketbase v0.25.0/go.mod h1:tOtOv7f3vJhAiyUluIwV9JPuKeknZRQ9F6uJE3W/ntI=
github.com/pocketbase/pocketbase v0.25.9 h1:/PSJcy39vEGv4lsBG4HV0ZFLcFsTdK9oMkJbxVlVJSs=
github.com/pocketbase/pocketbase v0.25.9/go.mod h1:gOnPr+g/GS+iqKh5XYXycdRWVGhiHY4c1H4TGjU9DDw=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
@@ -207,13 +207,12 @@ github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzx
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.25.1 h1:QSWkTc+fu9LTAWfkZwZ6j8MSUk4A2LV7rbH0ZqmLjXs=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
@@ -240,10 +239,10 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.59.0/go.mod h1:ijPqXp5P6IRRByFVVg9DY8P5HkxkHE5ARIa+86aXPf4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
@@ -259,19 +258,19 @@ gocloud.dev v0.40.0/go.mod h1:drz+VyYNBvrMTW0KZiBAYEdl8lbNZx+OQ7oQvdrFmSQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
golang.org/x/exp v0.0.0-20250228200357-dead58393ab7/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -280,12 +279,12 @@ golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE=
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -312,20 +311,20 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
google.golang.org/api v0.220.0 h1:3oMI4gdBgB72WFVwE1nerDD8W3HUOS4kypK6rRLbGns=
google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY=
google.golang.org/api v0.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc=
google.golang.org/api v0.223.0/go.mod h1:C+RS7Z+dDwds2b+zoAk5hN/eSfsiCn0UDrYof/M4d2M=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -337,8 +336,8 @@ google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phr
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 h1:5bKytslY8ViY0Cj/ewmRtrWHW64bNF03cAatUUFCdFI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e h1:YA5lmSs3zc/5w+xsRcHqpETkaYyK63ivEPzNTcUUlSA=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
@@ -355,8 +354,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -369,27 +368,27 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.23.16/go.mod h1:nNma8goMTY7aQZQNTyN9AIoJfxav4nvTnvKThAeMDdo=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/gc/v2 v2.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.61.13/go.mod h1:8F/uJWL/3nNil0Lgt1Dpz+GgkApWh04N3el3hxJcA6E=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.8.2 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -9,6 +9,7 @@ import (
"os"
"strings"
"sync"
"time"
"github.com/shirou/gopsutil/v4/common"
)
@@ -27,11 +28,14 @@ type Agent struct {
sensorsWhitelist map[string]struct{} // List of sensors to monitor
systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
}
func NewAgent() *Agent {
agent := &Agent{
fsStats: make(map[string]*system.FsStats),
sensorsContext: context.Background(),
fsStats: make(map[string]*system.FsStats),
cache: NewSessionCache(69 * time.Second),
}
agent.memCalc, _ = GetEnv("MEM_CALC")
@@ -56,14 +60,12 @@ func NewAgent() *Agent {
agent.sensorsContext = context.WithValue(agent.sensorsContext,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
)
} else {
agent.sensorsContext = context.Background()
}
// Set sensors whitelist
if sensors, exists := GetEnv("SENSORS"); exists {
agent.sensorsWhitelist = make(map[string]struct{})
for _, sensor := range strings.Split(sensors, ",") {
for sensor := range strings.SplitSeq(sensors, ",") {
if sensor != "" {
agent.sensorsWhitelist[sensor] = struct{}{}
}
@@ -85,7 +87,7 @@ func NewAgent() *Agent {
// if debugging, print stats
if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats())
slog.Debug("Stats", "data", agent.gatherStats(""))
}
return agent
@@ -100,29 +102,37 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key)
}
func (a *Agent) gatherStats() system.CombinedData {
func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
a.Lock()
defer a.Unlock()
slog.Debug("Getting stats")
systemData := system.CombinedData{
cachedData, ok := a.cache.Get(sessionID)
if ok {
slog.Debug("Cached stats", "session", sessionID)
return cachedData
}
*cachedData = system.CombinedData{
Stats: a.getSystemStats(),
Info: a.systemInfo,
}
slog.Debug("System stats", "data", systemData)
// add docker stats
slog.Debug("System stats", "data", cachedData)
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
systemData.Containers = containerStats
slog.Debug("Docker stats", "data", systemData.Containers)
cachedData.Containers = containerStats
slog.Debug("Docker stats", "data", cachedData.Containers)
} else {
slog.Debug("Error getting docker stats", "err", err)
slog.Debug("Docker stats", "err", err)
}
// add extra filesystems
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 {
systemData.Stats.ExtraFs[name] = stats
cachedData.Stats.ExtraFs[name] = stats
}
}
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
return systemData
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
a.cache.Set(sessionID, cachedData)
return cachedData
}

View File

@@ -0,0 +1,36 @@
package agent
import (
"beszel/internal/entities/system"
"time"
)
// Not thread safe since we only access from gatherStats which is already locked
type SessionCache struct {
data *system.CombinedData
lastUpdate time.Time
primarySession string
leaseTime time.Duration
}
func NewSessionCache(leaseTime time.Duration) *SessionCache {
return &SessionCache{
leaseTime: leaseTime,
data: &system.CombinedData{},
}
}
func (c *SessionCache) Get(sessionID string) (stats *system.CombinedData, isCached bool) {
if sessionID != c.primarySession && time.Since(c.lastUpdate) < c.leaseTime {
return c.data, true
}
return c.data, false
}
func (c *SessionCache) Set(sessionID string, data *system.CombinedData) {
if data != nil {
*c.data = *data
}
c.primarySession = sessionID
c.lastUpdate = time.Now()
}

View File

@@ -0,0 +1,85 @@
package agent
import (
"beszel/internal/entities/system"
"testing"
"testing/synctest"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestSessionCache_GetSet(t *testing.T) {
synctest.Run(func() {
cache := NewSessionCache(69 * time.Second)
testData := &system.CombinedData{
Info: system.Info{
Hostname: "test-host",
Cores: 4,
},
Stats: system.Stats{
Cpu: 50.0,
MemPct: 30.0,
DiskPct: 40.0,
},
}
// Test initial state - should not be cached
data, isCached := cache.Get("session1")
assert.False(t, isCached, "Expected no cached data initially")
assert.NotNil(t, data, "Expected data to be initialized")
// Set data for session1
cache.Set("session1", testData)
time.Sleep(15 * time.Second)
// Get data for a different session - should be cached
data, isCached = cache.Get("session2")
assert.True(t, isCached, "Expected data to be cached for non-primary session")
require.NotNil(t, data, "Expected cached data to be returned")
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
assert.Equal(t, 4, data.Info.Cores, "Cores should match test data")
assert.Equal(t, 50.0, data.Stats.Cpu, "CPU should match test data")
assert.Equal(t, 30.0, data.Stats.MemPct, "Memory percentage should match test data")
assert.Equal(t, 40.0, data.Stats.DiskPct, "Disk percentage should match test data")
time.Sleep(10 * time.Second)
// Get data for the primary session - should not be cached
data, isCached = cache.Get("session1")
assert.False(t, isCached, "Expected data not to be cached for primary session")
require.NotNil(t, data, "Expected data to be returned even if not cached")
assert.Equal(t, "test-host", data.Info.Hostname, "Hostname should match test data")
// if not cached, agent will update the data
cache.Set("session1", testData)
time.Sleep(45 * time.Second)
// Get data for a different session - should still be cached
_, isCached = cache.Get("session2")
assert.True(t, isCached, "Expected data to be cached for non-primary session")
// Wait for the lease to expire
time.Sleep(30 * time.Second)
// Get data for session2 - should not be cached
_, isCached = cache.Get("session2")
assert.False(t, isCached, "Expected data not to be cached after lease expiration")
})
}
func TestSessionCache_NilData(t *testing.T) {
// Create a new SessionCache
cache := NewSessionCache(30 * time.Second)
// Test setting nil data (should not panic)
assert.NotPanics(t, func() {
cache.Set("session1", nil)
}, "Setting nil data should not panic")
// Get data - should not be nil even though we set nil
data, _ := cache.Get("session2")
assert.NotNil(t, data, "Expected data to not be nil after setting nil data")
}

View File

@@ -22,12 +22,24 @@ type dockerManager struct {
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
apiContainerList *[]container.ApiInfo // List of containers from Docker API
apiContainerList []*container.ApiInfo // List of containers from Docker API (no pointer)
containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
type userAgentRoundTripper struct {
rt http.RoundTripper
userAgent string
}
// RoundTrip implements the http.RoundTripper interface
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", u.userAgent)
return u.rt.RoundTrip(req)
}
// Add goroutine to the queue
func (d *dockerManager) queue() {
d.wg.Add(1)
@@ -52,11 +64,12 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
}
defer resp.Body.Close()
dm.apiContainerList = dm.apiContainerList[:0]
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
return nil, err
}
containersLength := len(*dm.apiContainerList)
containersLength := len(dm.apiContainerList)
// store valid ids to clean up old container ids from map
if dm.validIds == nil {
@@ -65,9 +78,10 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
clear(dm.validIds)
}
var failedContainters []container.ApiInfo
var failedContainers []*container.ApiInfo
for _, ctr := range *dm.apiContainerList {
for i := range dm.apiContainerList {
ctr := dm.apiContainerList[i]
ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart)
@@ -84,7 +98,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
if err != nil {
dm.containerStatsMutex.Lock()
delete(dm.containerStatsMap, ctr.IdShort)
failedContainters = append(failedContainters, ctr)
failedContainers = append(failedContainers, ctr)
dm.containerStatsMutex.Unlock()
}
}()
@@ -93,9 +107,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
dm.wg.Wait()
// retry failed containers separately so we can run them in parallel (docker 24 bug)
if len(failedContainters) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainters))
for _, ctr := range failedContainters {
if len(failedContainers) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainers))
for _, ctr := range failedContainers {
dm.queue()
go func() {
defer dm.dequeue()
@@ -122,7 +136,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
}
// Updates stats for individual container
func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo) error {
name := ctr.Names[0][1:]
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=1")
@@ -251,20 +265,27 @@ func newDockerManager(a *Agent) *dockerManager {
slog.Info("DOCKER_TIMEOUT", "timeout", timeout)
}
dockerClient := &dockerManager{
// Custom user-agent to avoid docker bug: https://github.com/docker/for-mac/issues/7575
userAgentTransport := &userAgentRoundTripper{
rt: transport,
userAgent: "Docker-Client/",
}
manager := &dockerManager{
client: &http.Client{
Timeout: timeout,
Transport: transport,
Transport: userAgentTransport,
},
containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
}
// If using podman, return client
if strings.Contains(dockerHost, "podman") {
a.systemInfo.Podman = true
dockerClient.goodDockerVersion = true
return dockerClient
manager.goodDockerVersion = true
return manager
}
// Check docker version
@@ -272,23 +293,24 @@ func newDockerManager(a *Agent) *dockerManager {
var versionInfo struct {
Version string `json:"Version"`
}
resp, err := dockerClient.client.Get("http://localhost/version")
resp, err := manager.client.Get("http://localhost/version")
if err != nil {
return dockerClient
return manager
}
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil {
return dockerClient
return manager
}
// if version > 24, one-shot works correctly and we can limit concurrent operations
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
dockerClient.goodDockerVersion = true
manager.goodDockerVersion = true
} else {
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version))
}
return dockerClient
return manager
}
// Test docker / podman sockets and return if one exists

View File

@@ -3,6 +3,7 @@ package agent
import (
"beszel/internal/entities/system"
"bufio"
"bytes"
"encoding/json"
"fmt"
"os/exec"
@@ -75,7 +76,7 @@ func (c *gpuCollector) collect() error {
scanner := bufio.NewScanner(stdout)
if c.buf == nil {
c.buf = make([]byte, 0, 4*1024)
c.buf = make([]byte, 0, 10*1024)
}
scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
@@ -110,27 +111,26 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
if !ok {
return true
}
data := string(output)
// Parse RAM usage
ramMatches := ramPattern.FindStringSubmatch(data)
ramMatches := ramPattern.FindSubmatch(output)
if ramMatches != nil {
gpuData.MemoryUsed, _ = strconv.ParseFloat(ramMatches[1], 64)
gpuData.MemoryTotal, _ = strconv.ParseFloat(ramMatches[2], 64)
gpuData.MemoryUsed, _ = strconv.ParseFloat(string(ramMatches[1]), 64)
gpuData.MemoryTotal, _ = strconv.ParseFloat(string(ramMatches[2]), 64)
}
// Parse GR3D (GPU) usage
gr3dMatches := gr3dPattern.FindStringSubmatch(data)
gr3dMatches := gr3dPattern.FindSubmatch(output)
if gr3dMatches != nil {
gpuData.Usage, _ = strconv.ParseFloat(gr3dMatches[1], 64)
gpuData.Usage, _ = strconv.ParseFloat(string(gr3dMatches[1]), 64)
}
// Parse temperature
tempMatches := tempPattern.FindStringSubmatch(data)
tempMatches := tempPattern.FindSubmatch(output)
if tempMatches != nil {
gpuData.Temperature, _ = strconv.ParseFloat(tempMatches[1], 64)
gpuData.Temperature, _ = strconv.ParseFloat(string(tempMatches[1]), 64)
}
// Parse power usage
powerMatches := powerPattern.FindStringSubmatch(data)
powerMatches := powerPattern.FindSubmatch(output)
if powerMatches != nil {
power, _ := strconv.ParseFloat(powerMatches[2], 64)
power, _ := strconv.ParseFloat(string(powerMatches[2]), 64)
gpuData.Power = power / 1000
}
gpuData.Count++
@@ -142,8 +142,10 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
func (gm *GPUManager) parseNvidiaData(output []byte) bool {
gm.Lock()
defer gm.Unlock()
scanner := bufio.NewScanner(bytes.NewReader(output))
var valid bool
for line := range strings.Lines(string(output)) {
for scanner.Scan() {
line := scanner.Text() // Or use scanner.Bytes() for []byte
fields := strings.Split(strings.TrimSpace(line), ", ")
if len(fields) < 7 {
continue

View File

@@ -61,8 +61,8 @@ func (a *Agent) StartServer(opts ServerOptions) error {
}
func (a *Agent) handleSession(s sshServer.Session) {
// slog.Debug("connection", "remoteaddr", s.RemoteAddr(), "user", s.User())
stats := a.gatherStats()
slog.Debug("New session", "client", s.RemoteAddr())
stats := a.gatherStats(s.Context().SessionID())
if err := json.NewEncoder(s).Encode(stats); err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1)
@@ -74,24 +74,18 @@ func (a *Agent) handleSession(s sshServer.Session) {
// It returns a slice of ssh.PublicKey and an error if any key fails to parse.
func ParseKeys(input string) ([]ssh.PublicKey, error) {
var parsedKeys []ssh.PublicKey
for line := range strings.Lines(input) {
line = strings.TrimSpace(line)
// Skip empty lines or comments
if len(line) == 0 || strings.HasPrefix(line, "#") {
continue
}
// Parse the key
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
}
// Append the parsed key to the list
parsedKeys = append(parsedKeys, parsedKey)
}
return parsedKeys, nil
}

View File

@@ -187,7 +187,7 @@ func (a *Agent) getSystemStats() system.Stats {
// temperatures (skip if sensors whitelist is set to empty string)
err = a.updateTemperatures(&systemStats)
if err != nil {
slog.Error("Error getting temperatures", "err", err)
slog.Error("Error getting temperatures", "err", fmt.Sprintf("%+v", err))
}
// GPU data
@@ -238,6 +238,7 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) error {
// get sensor data
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil {
slog.Error("Error getting temperatures", "err", fmt.Sprintf("%+v", err))
return err
}
slog.Debug("Temperature", "sensors", temps)

View File

@@ -26,8 +26,7 @@ type alertInfo struct {
// startWorker is a long-running goroutine that processes alert tasks
// every x seconds. It must be running to process status alerts.
func (am *AlertManager) startWorker() {
// no special reason for 13 seconds
tick := time.Tick(13 * time.Second)
tick := time.Tick(15 * time.Second)
for {
select {
case <-am.stopChan:
@@ -64,21 +63,12 @@ func (am *AlertManager) StopWorker() {
}
// HandleStatusAlerts manages the logic when system status changes.
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
switch newStatus {
case "up":
if oldSystemRecord.GetString("status") != "down" {
return nil
}
case "down":
if oldSystemRecord.GetString("status") != "up" {
return nil
}
default:
func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.Record) error {
if newStatus != "up" && newStatus != "down" {
return nil
}
alertRecords, err := am.getSystemStatusAlerts(oldSystemRecord.Id)
alertRecords, err := am.getSystemStatusAlerts(systemRecord.Id)
if err != nil {
return err
}
@@ -86,7 +76,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *co
return nil
}
systemName := oldSystemRecord.GetString("name")
systemName := systemRecord.GetString("name")
if newStatus == "down" {
am.handleSystemDown(systemName, alertRecords)
} else {

View File

@@ -4,7 +4,6 @@ import (
"beszel/internal/entities/system"
"fmt"
"net/url"
"slices"
"strings"
"time"
@@ -15,7 +14,7 @@ import (
"github.com/spf13/cast"
)
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
alertRecords, err := am.app.FindAllRecords("alerts",
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
)
@@ -35,15 +34,15 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
switch name {
case "CPU":
val = systemInfo.Cpu
val = data.Info.Cpu
case "Memory":
val = systemInfo.MemPct
val = data.Info.MemPct
case "Bandwidth":
val = systemInfo.Bandwidth
val = data.Info.Bandwidth
unit = " MB/s"
case "Disk":
maxUsedPct := systemInfo.DiskPct
for _, fs := range extraFs {
maxUsedPct := data.Info.DiskPct
for _, fs := range data.Stats.ExtraFs {
usedPct := fs.DiskUsed / fs.DiskTotal * 100
if usedPct > maxUsedPct {
maxUsedPct = usedPct
@@ -51,14 +50,10 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
}
val = maxUsedPct
case "Temperature":
if temperatures == nil {
if data.Info.DashboardTemp < 1 {
continue
}
for _, temp := range temperatures {
if temp > val {
val = temp
}
}
val = data.Info.DashboardTemp
unit = "°C"
}
@@ -74,13 +69,8 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
}
min := max(1, cast.ToUint8(alertRecord.Get("min")))
// add time to alert time to make sure it's slighty after record creation
time := now.Add(-time.Duration(min) * time.Minute)
if time.Before(oldestTime) {
oldestTime = time
}
validAlerts = append(validAlerts, SystemAlertData{
alert := SystemAlertData{
systemRecord: systemRecord,
alertRecord: alertRecord,
name: name,
@@ -88,9 +78,22 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
val: val,
threshold: threshold,
triggered: triggered,
time: time,
min: min,
})
}
// send alert immediately if min is 1 - no need to sum up values.
if min == 1 {
alert.triggered = val > threshold
go am.sendSystemAlert(alert)
continue
}
alert.time = now.Add(-time.Duration(min) * time.Minute)
if alert.time.Before(oldestTime) {
oldestTime = alert.time
}
validAlerts = append(validAlerts, alert)
}
systemStats := []struct {
@@ -111,7 +114,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
)).
OrderBy("created").
All(&systemStats)
if err != nil {
if err != nil || len(systemStats) == 0 {
return err
}
@@ -119,13 +122,14 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
oldestRecordTime := systemStats[0].Created.Time()
// log.Println("oldestRecordTime", oldestRecordTime.String())
// delete from validAlerts if time is older than oldestRecord
for i := range validAlerts {
if validAlerts[i].time.Before(oldestRecordTime) {
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
validAlerts = slices.Delete(validAlerts, i, i+1)
// Filter validAlerts to keep only those with time newer than oldestRecord
filteredAlerts := make([]SystemAlertData, 0, len(validAlerts))
for _, alert := range validAlerts {
if alert.time.After(oldestRecordTime) {
filteredAlerts = append(filteredAlerts, alert)
}
}
validAlerts = filteredAlerts
if len(validAlerts) == 0 {
// log.Println("no valid alerts found")
@@ -163,7 +167,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
alert.val += stats.NetSent + stats.NetRecv
case "Disk":
if alert.mapSums == nil {
alert.mapSums = make(map[string]float32, len(extraFs)+1)
alert.mapSums = make(map[string]float32, len(data.Stats.ExtraFs)+1)
}
// add root disk
if _, ok := alert.mapSums["root"]; !ok {
@@ -171,7 +175,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo
}
alert.mapSums["root"] += float32(stats.Disk)
// add extra disks
for key, fs := range extraFs {
for key, fs := range data.Stats.ExtraFs {
if _, ok := alert.mapSums[key]; !ok {
alert.mapSums[key] = 0.0
}

View File

@@ -1,5 +1,7 @@
package system
// TODO: this is confusing, make common package with common/types common/helpers etc
import (
"beszel/internal/entities/container"
"time"

View File

@@ -22,7 +22,7 @@ type Config struct {
type SystemConfig struct {
Name string `yaml:"name"`
Host string `yaml:"host"`
Port uint16 `yaml:"port"`
Port uint16 `yaml:"port,omitempty"`
Users []string `yaml:"users"`
}

View File

@@ -4,67 +4,47 @@ package hub
import (
"beszel"
"beszel/internal/alerts"
"beszel/internal/entities/system"
"beszel/internal/hub/systems"
"beszel/internal/records"
"beszel/internal/users"
"beszel/site"
"context"
"crypto/ed25519"
"encoding/pem"
"fmt"
"io/fs"
"log"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh"
)
type Hub struct {
*pocketbase.PocketBase
sshClientConfig *ssh.ClientConfig
pubKey string
am *alerts.AlertManager
um *users.UserManager
rm *records.RecordManager
systemStats *core.Collection
containerStats *core.Collection
appURL string
core.App
*alerts.AlertManager
um *users.UserManager
rm *records.RecordManager
sm *systems.SystemManager
pubKey string
appURL string
}
// NewHub creates a new Hub instance with default configuration
func NewHub() *Hub {
var hub Hub
hub.PocketBase = pocketbase.NewWithConfig(pocketbase.Config{
DefaultDataDir: beszel.AppName + "_data",
})
func NewHub(app core.App) *Hub {
hub := &Hub{}
hub.App = app
hub.RootCmd.Version = beszel.Version
hub.RootCmd.Use = beszel.AppName
hub.RootCmd.Short = ""
// add update command
hub.RootCmd.AddCommand(&cobra.Command{
Use: "update",
Short: "Update " + beszel.AppName + " to the latest version",
Run: Update,
})
hub.am = alerts.NewAlertManager(hub)
hub.AlertManager = alerts.NewAlertManager(hub)
hub.um = users.NewUserManager(hub)
hub.rm = records.NewRecordManager(hub)
hub.sm = systems.NewSystemManager(hub)
hub.appURL, _ = GetEnv("APP_URL")
return &hub
return hub
}
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
@@ -76,444 +56,183 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key)
}
func (h *Hub) Run() {
isDev := os.Getenv("ENV") == "dev"
// enable auto creation of migration files when making collection changes in the Admin UI
migratecmd.MustRegister(h, h.RootCmd, migratecmd.Config{
// (the isDev check is to enable it only during development)
Automigrate: isDev,
Dir: "../../migrations",
})
// initial setup
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
// create ssh client config
err := h.createSSHClientConfig()
func (h *Hub) BootstrapHub() (*Hub, error) {
if !h.App.IsBootstrapped() {
err := h.App.Bootstrap()
if err != nil {
log.Fatal(err)
return nil, err
}
// set general settings
settings := h.Settings()
// batch requests (for global alerts)
settings.Batch.Enabled = true
// set URL if BASE_URL env is set
if h.appURL != "" {
settings.Meta.AppURL = h.appURL
}
// set auth settings
usersCollection, err := h.FindCollectionByNameOrId("users")
if err != nil {
return err
}
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
if usersCollection.OAuth2.Enabled {
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
}
// allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'"
usersCollection.CreateRule = &cr
} else {
usersCollection.CreateRule = nil
}
if err := h.Save(usersCollection); err != nil {
return err
}
// sync systems with config
h.syncSystemsWithConfig()
return se.Next()
})
}
// serve web ui
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
switch isDev {
case true:
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "localhost:5173",
})
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
proxy.ServeHTTP(e.Response, e.Request)
return nil
})
default:
// 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")
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
// set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false)
// get CSP configuration
csp, cspExists := GetEnv("CSP")
// add route
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
// serve static assets if path is in staticPaths
for i := range staticPaths {
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
return serveStatic(e)
}
}
if cspExists {
e.Response.Header().Del("X-Frame-Options")
e.Response.Header().Set("Content-Security-Policy", csp)
}
return e.HTML(http.StatusOK, indexContent)
})
}
return se.Next()
})
// set up scheduled jobs / ticker for system updates
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
// 15 second ticker for system updates
go h.startSystemUpdateTicker()
// set up cron jobs
// delete old records once every hour
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
h.Cron().MustAdd("create longer records", "*/10 * * * *", func() {
if systemStats, containerStats, err := h.getCollections(); err == nil {
h.rm.CreateLongerRecords([]*core.Collection{systemStats, containerStats})
}
})
return se.Next()
})
h.OnServe().BindFunc(h.startServer)
// set up scheduled jobs
h.OnServe().BindFunc(h.registerCronJobs)
// custom api routes
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
// returns public key
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
})
// check if first time setup on login page
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
total, err := h.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// send test notification
se.Router.GET("/api/beszel/send-test-notification", h.am.SendTestNotification)
// API endpoint to get config.yml content
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
// create first user endpoint only needed if no users exist
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
}
return se.Next()
})
// system creation defaults
h.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", "pending")
return e.Next()
})
// immediately create connection for new systems
h.OnRecordAfterCreateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
go h.updateSystem(e.Record)
return e.Next()
})
h.OnServe().BindFunc(h.registerApiRoutes)
// TODO: move to users package
// handle default values for user / user_settings creation
h.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
// empty info for systems that are paused
h.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
if e.Record.GetString("status") == "paused" {
e.Record.Set("info", system.Info{})
}
return e.Next()
})
// sync systems with config
h.syncSystemsWithConfig()
// start system updates
h.sm.Initialize()
// do things after a systems record is updated
h.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
newRecord := e.Record.Fresh()
oldRecord := newRecord.Original()
newStatus := newRecord.GetString("status")
// if system is not up and connection exists, remove it
if newStatus != "up" {
h.deleteSystemConnection(newRecord)
}
// if system is set to pending (unpause), try to connect immediately
if newStatus == "pending" {
go h.updateSystem(newRecord)
} else {
h.am.HandleStatusAlerts(newStatus, oldRecord)
}
return e.Next()
})
// if system is deleted, close connection
h.OnRecordAfterDeleteSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
h.deleteSystemConnection(e.Record)
return e.Next()
})
if err := h.Start(); err != nil {
log.Fatal(err)
}
}
func (h *Hub) startSystemUpdateTicker() {
c := time.Tick(15 * time.Second)
for range c {
h.updateSystems()
}
}
func (h *Hub) updateSystems() {
records, err := h.FindRecordsByFilter(
"2hz5ncl8tizk5nx", // systems collection
"status != 'paused'", // filter
"updated", // sort
-1, // limit
0, // offset
)
// log.Println("records", len(records))
if err != nil || len(records) == 0 {
// h.Logger().Error("Failed to query systems")
return
}
fiftySecondsAgo := time.Now().UTC().Add(-50 * time.Second)
batchSize := len(records)/4 + 1
done := 0
for _, record := range records {
// break if batch size reached or if the system was updated less than 50 seconds ago
if done >= batchSize || record.GetDateTime("updated").Time().After(fiftySecondsAgo) {
break
}
// don't increment for down systems to avoid them jamming the queue
// because they're always first when sorted by least recently updated
if record.GetString("status") != "down" {
done++
}
go h.updateSystem(record)
}
}
func (h *Hub) updateSystem(record *core.Record) {
var client *ssh.Client
var err error
// check if system connection exists
if existingClient, ok := h.Store().GetOk(record.Id); ok {
client = existingClient.(*ssh.Client)
} else {
// create system connection
client, err = h.createSystemConnection(record)
if err != nil {
if record.GetString("status") != "down" {
h.Logger().Error("Failed to connect:", "err", err.Error(), "system", record.GetString("host"), "port", record.GetString("port"))
h.updateSystemStatus(record, "down")
}
return
}
h.Store().Set(record.Id, client)
}
// get system stats from agent
var systemData system.CombinedData
if err := h.requestJsonFromAgent(client, &systemData); err != nil {
if err.Error() == "bad client" {
// if previous connection was closed, try again
h.Logger().Error("Existing SSH connection closed. Retrying...", "host", record.GetString("host"), "port", record.GetString("port"))
h.deleteSystemConnection(record)
time.Sleep(time.Millisecond * 100)
h.updateSystem(record)
return
}
h.Logger().Error("Failed to get system stats: ", "err", err.Error())
h.updateSystemStatus(record, "down")
return
}
// update system record
record.Set("status", "up")
record.Set("info", systemData.Info)
if err := h.SaveNoValidate(record); err != nil {
h.Logger().Error("Failed to update record: ", "err", err.Error())
}
// add system_stats and container_stats records
if systemStats, containerStats, err := h.getCollections(); err != nil {
h.Logger().Error("Failed to get collections: ", "err", err.Error())
} else {
// add new system_stats record
systemStatsRecord := core.NewRecord(systemStats)
systemStatsRecord.Set("system", record.Id)
systemStatsRecord.Set("stats", systemData.Stats)
systemStatsRecord.Set("type", "1m")
if err := h.SaveNoValidate(systemStatsRecord); err != nil {
h.Logger().Error("Failed to save record: ", "err", err.Error())
}
// add new container_stats record
if len(systemData.Containers) > 0 {
containerStatsRecord := core.NewRecord(containerStats)
containerStatsRecord.Set("system", record.Id)
containerStatsRecord.Set("stats", systemData.Containers)
containerStatsRecord.Set("type", "1m")
if err := h.SaveNoValidate(containerStatsRecord); err != nil {
h.Logger().Error("Failed to save record: ", "err", err.Error())
}
}
}
// system info alerts
if err := h.am.HandleSystemAlerts(record, systemData.Info, systemData.Stats.Temperatures, systemData.Stats.ExtraFs); err != nil {
h.Logger().Error("System alerts error", "err", err.Error())
}
}
// return system_stats and container_stats collections
func (h *Hub) getCollections() (*core.Collection, *core.Collection, error) {
if h.systemStats == nil {
systemStats, err := h.FindCollectionByNameOrId("system_stats")
if err != nil {
return nil, nil, err
}
h.systemStats = systemStats
}
if h.containerStats == nil {
containerStats, err := h.FindCollectionByNameOrId("container_stats")
if err != nil {
return nil, nil, err
}
h.containerStats = containerStats
}
return h.systemStats, h.containerStats, nil
}
// set system to specified status and save record
func (h *Hub) updateSystemStatus(record *core.Record, status string) {
if record.Fresh().GetString("status") != status {
record.Set("status", status)
if err := h.SaveNoValidate(record); err != nil {
h.Logger().Error("Failed to update record: ", "err", err.Error())
}
}
}
// delete system connection from map and close connection
func (h *Hub) deleteSystemConnection(record *core.Record) {
if client, ok := h.Store().GetOk(record.Id); ok {
if sshClient := client.(*ssh.Client); sshClient != nil {
sshClient.Close()
}
h.Store().Remove(record.Id)
}
}
func (h *Hub) createSystemConnection(record *core.Record) (*ssh.Client, error) {
network := "tcp"
host := record.GetString("host")
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, record.GetString("port"))
}
client, err := ssh.Dial(network, host, h.sshClientConfig)
if err != nil {
// initial setup
if err := h.initialize(); err != nil {
return nil, err
}
return client, nil
return h, nil
}
func (h *Hub) createSSHClientConfig() error {
key, err := h.getSSHKey()
if err != nil {
h.Logger().Error("Failed to get SSH key: ", "err", err.Error())
return err
// initialize sets up initial configuration (collections, settings, etc.)
func (h *Hub) initialize() error {
// set general settings
settings := h.Settings()
// batch requests (for global alerts)
settings.Batch.Enabled = true
// set URL if BASE_URL env is set
if h.appURL != "" {
settings.Meta.AppURL = h.appURL
}
// Create the Signer for this private key.
signer, err := ssh.ParsePrivateKey(key)
// set auth settings
usersCollection, err := h.FindCollectionByNameOrId("users")
if err != nil {
return err
}
h.sshClientConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
// disable oauth if no providers are configured (todo: remove this in post 0.9.0 release)
if usersCollection.OAuth2.Enabled {
usersCollection.OAuth2.Enabled = len(usersCollection.OAuth2.Providers) > 0
}
// allow oauth user creation if USER_CREATION is set
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
cr := "@request.context = 'oauth2'"
usersCollection.CreateRule = &cr
} else {
usersCollection.CreateRule = nil
}
if err := h.Save(usersCollection); err != nil {
return err
}
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
systemsCollection, err := h.FindCachedCollectionByNameOrId("systems")
if err != nil {
return err
}
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
systemsReadRule := "@request.auth.id != \"\""
if shareAllSystems != "true" {
// default is to only show systems that the user id is assigned to
systemsReadRule += " && users.id ?= @request.auth.id"
}
updateDeleteRule := systemsReadRule + " && @request.auth.role != \"readonly\""
systemsCollection.ListRule = &systemsReadRule
systemsCollection.ViewRule = &systemsReadRule
systemsCollection.UpdateRule = &updateDeleteRule
systemsCollection.DeleteRule = &updateDeleteRule
if err := h.Save(systemsCollection); err != nil {
return err
}
return nil
}
// Fetches system stats from the agent and decodes the json data into the provided struct
func (h *Hub) requestJsonFromAgent(client *ssh.Client, systemData *system.CombinedData) error {
session, err := newSessionWithTimeout(client, 4*time.Second)
if err != nil {
return fmt.Errorf("bad client")
// Start starts the hub application / server
func (h *Hub) Start() error {
if pb, ok := h.App.(*pocketbase.PocketBase); ok {
return pb.Start()
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return err
}
if err := session.Shell(); err != nil {
return err
}
if err := json.NewDecoder(stdout).Decode(systemData); err != nil {
return err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return err
}
return nil
return fmt.Errorf("unable to start: App is not *pocketbase.PocketBase")
}
// Adds timeout to SSH session creation to avoid hanging in case of network issues
func newSessionWithTimeout(client *ssh.Client, timeout time.Duration) (*ssh.Session, error) {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// use goroutine to create the session
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := client.NewSession(); err != nil {
errChan <- err
} else {
sessionChan <- session
func (h *Hub) startServer(se *core.ServeEvent) error {
switch h.IsDev() {
case true:
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "localhost:5173",
})
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
proxy.ServeHTTP(e.Response, e.Request)
return nil
})
default:
// parse app url
parsedURL, err := url.Parse(h.appURL)
if err != nil {
return err
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("session creation timed out")
// fix base paths in html if using subpath
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
indexContent := strings.ReplaceAll(string(indexFile), "./", basePath)
// set up static asset serving
staticPaths := [2]string{"/static/", "/assets/"}
serveStatic := apis.Static(site.DistDirFS, false)
// get CSP configuration
csp, cspExists := GetEnv("CSP")
// add route
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
// serve static assets if path is in staticPaths
for i := range staticPaths {
if strings.Contains(e.Request.URL.Path, staticPaths[i]) {
e.Response.Header().Set("Cache-Control", "public, max-age=2592000")
return serveStatic(e)
}
}
if cspExists {
e.Response.Header().Del("X-Frame-Options")
e.Response.Header().Set("Content-Security-Policy", csp)
}
return e.HTML(http.StatusOK, indexContent)
})
}
return se.Next()
}
func (h *Hub) getSSHKey() ([]byte, error) {
// registerCronJobs sets up all scheduled tasks
func (h *Hub) registerCronJobs(se *core.ServeEvent) error {
// delete old records once every hour
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords)
// create longer records every 10 minutes
h.Cron().MustAdd("create longer records", "*/10 * * * *", h.rm.CreateLongerRecords)
return se.Next()
}
// custom api routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// returns public key and version
se.Router.GET("/api/beszel/getkey", func(e *core.RequestEvent) error {
info, _ := e.RequestInfo()
if info.Auth == nil {
return apis.NewForbiddenError("Forbidden", nil)
}
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
})
// check if first time setup on login page
se.Router.GET("/api/beszel/first-run", func(e *core.RequestEvent) error {
total, err := h.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// send test notification
se.Router.GET("/api/beszel/send-test-notification", h.SendTestNotification)
// API endpoint to get config.yml content
se.Router.GET("/api/beszel/config-yaml", h.getYamlConfig)
// create first user endpoint only needed if no users exist
if totalUsers, _ := h.CountRecords("users"); totalUsers == 0 {
se.Router.POST("/api/beszel/create-user", h.um.CreateFirstUser)
}
return se.Next()
}
// generates key pair if it doesn't exist and returns private key bytes
func (h *Hub) GetSSHKey() ([]byte, error) {
dataDir := h.DataDir()
// check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519")

View File

@@ -0,0 +1,435 @@
package systems
import (
"beszel/internal/entities/system"
"context"
"fmt"
"net"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store"
"golang.org/x/crypto/ssh"
)
const (
up string = "up"
down string = "down"
paused string = "paused"
pending string = "pending"
interval int = 60_000
sessionTimeout = 4 * time.Second
)
type SystemManager struct {
hub hubLike
systems *store.Store[string, *System]
sshConfig *ssh.ClientConfig
}
type System struct {
Id string `db:"id"`
Host string `db:"host"`
Port string `db:"port"`
Status string `db:"status"`
manager *SystemManager
client *ssh.Client
data *system.CombinedData
ctx context.Context
cancel context.CancelFunc
}
type hubLike interface {
core.App
GetSSHKey() ([]byte, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
}
func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
hub: hub,
}
}
// Initialize initializes the system manager.
// It binds the event hooks and starts updating existing systems.
func (sm *SystemManager) Initialize() error {
sm.bindEventHooks()
// ssh setup
key, err := sm.hub.GetSSHKey()
if err != nil {
return err
}
if err := sm.createSSHClientConfig(key); err != nil {
return err
}
// start updating existing systems
var systems []*System
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
if err != nil || len(systems) == 0 {
return err
}
go func() {
// time between initial system updates
delta := interval / max(1, len(systems))
delta = min(delta, 2_000)
sleepTime := time.Duration(delta) * time.Millisecond
for _, system := range systems {
time.Sleep(sleepTime)
_ = sm.AddSystem(system)
}
}()
return nil
}
func (sm *SystemManager) bindEventHooks() {
sm.hub.OnRecordCreate("systems").BindFunc(sm.onRecordCreate)
sm.hub.OnRecordAfterCreateSuccess("systems").BindFunc(sm.onRecordAfterCreateSuccess)
sm.hub.OnRecordUpdate("systems").BindFunc(sm.onRecordUpdate)
sm.hub.OnRecordAfterUpdateSuccess("systems").BindFunc(sm.onRecordAfterUpdateSuccess)
sm.hub.OnRecordAfterDeleteSuccess("systems").BindFunc(sm.onRecordAfterDeleteSuccess)
}
// Runs before the record is committed to the database
func (sm *SystemManager) onRecordCreate(e *core.RecordEvent) error {
e.Record.Set("info", system.Info{})
e.Record.Set("status", pending)
return e.Next()
}
// Runs after the record is committed to the database
func (sm *SystemManager) onRecordAfterCreateSuccess(e *core.RecordEvent) error {
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
// Runs before the record is updated
func (sm *SystemManager) onRecordUpdate(e *core.RecordEvent) error {
if e.Record.GetString("status") == paused {
e.Record.Set("info", system.Info{})
}
return e.Next()
}
// Runs after the record is updated
func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
newStatus := e.Record.GetString("status")
switch newStatus {
case paused:
sm.RemoveSystem(e.Record.Id)
return e.Next()
case pending:
if err := sm.AddRecord(e.Record); err != nil {
e.App.Logger().Error("Error adding record", "err", err)
}
return e.Next()
}
system, ok := sm.systems.GetOk(e.Record.Id)
if !ok {
return sm.AddRecord(e.Record)
}
prevStatus := system.Status
system.Status = newStatus
// system alerts if system is up
if system.Status == up {
if err := sm.hub.HandleSystemAlerts(e.Record, system.data); err != nil {
e.App.Logger().Error("Error handling system alerts", "err", err)
}
}
if (system.Status == down && prevStatus == up) || (system.Status == up && prevStatus == down) {
if err := sm.hub.HandleStatusAlerts(system.Status, e.Record); err != nil {
e.App.Logger().Error("Error handling status alerts", "err", err)
}
}
return e.Next()
}
// Runs after the record is deleted
func (sm *SystemManager) onRecordAfterDeleteSuccess(e *core.RecordEvent) error {
sm.RemoveSystem(e.Record.Id)
return e.Next()
}
// AddSystem adds a system to the manager
func (sm *SystemManager) AddSystem(sys *System) error {
if sm.systems.Has(sys.Id) {
return fmt.Errorf("system exists")
}
if sys.Id == "" || sys.Host == "" {
return fmt.Errorf("system is missing required fields")
}
sys.manager = sm
sys.ctx, sys.cancel = context.WithCancel(context.Background())
sys.data = &system.CombinedData{}
sm.systems.Set(sys.Id, sys)
go sys.StartUpdater()
return nil
}
// RemoveSystem removes a system from the manager
func (sm *SystemManager) RemoveSystem(systemID string) error {
system, ok := sm.systems.GetOk(systemID)
if !ok {
return fmt.Errorf("system not found")
}
// cancel the context to signal stop
if system.cancel != nil {
system.cancel()
}
system.resetSSHClient()
sm.systems.Remove(systemID)
return nil
}
// AddRecord adds a record to the system manager.
// It first removes any existing system with the same ID, then creates a new System
// instance from the record data and adds it to the manager.
// This function is typically called when a new system is created or when an existing
// system's status changes to pending.
func (sm *SystemManager) AddRecord(record *core.Record) (err error) {
_ = sm.RemoveSystem(record.Id)
system := &System{
Id: record.Id,
Status: record.GetString("status"),
Host: record.GetString("host"),
Port: record.GetString("port"),
}
return sm.AddSystem(system)
}
// StartUpdater starts the system updater.
// It first fetches the data from the agent then updates the records.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) StartUpdater() {
if sys.data == nil {
sys.data = &system.CombinedData{}
}
if err := sys.update(); err != nil {
_ = sys.setDown(err)
}
c := time.Tick(time.Duration(interval) * time.Millisecond)
for {
select {
case <-sys.ctx.Done():
return
case <-c:
err := sys.update()
if err != nil {
_ = sys.setDown(err)
}
}
}
}
// update updates the system data and records.
// It first fetches the data from the agent then updates the records.
func (sys *System) update() error {
_, err := sys.fetchDataFromAgent()
if err == nil {
_, err = sys.createRecords()
}
return err
}
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords() (*core.Record, error) {
systemRecord, err := sys.getRecord()
if err != nil {
return nil, err
}
hub := sys.manager.hub
// add system_stats and container_stats records
systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
return nil, err
}
systemStatsRecord := core.NewRecord(systemStats)
systemStatsRecord.Set("system", systemRecord.Id)
systemStatsRecord.Set("stats", sys.data.Stats)
systemStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(systemStatsRecord); err != nil {
return nil, err
}
// add new container_stats record
if len(sys.data.Containers) > 0 {
containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return nil, err
}
containerStatsRecord := core.NewRecord(containerStats)
containerStatsRecord.Set("system", systemRecord.Id)
containerStatsRecord.Set("stats", sys.data.Containers)
containerStatsRecord.Set("type", "1m")
if err := hub.SaveNoValidate(containerStatsRecord); err != nil {
return nil, err
}
}
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
systemRecord.Set("status", up)
systemRecord.Set("info", sys.data.Info)
if err := hub.SaveNoValidate(systemRecord); err != nil {
return nil, err
}
return systemRecord, nil
}
// getRecord retrieves the system record from the database.
// If the record is not found or the system is paused, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id)
return nil, err
}
return record, nil
}
// setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status.
func (sys *System) setDown(OriginalError error) error {
if sys.Status == down {
return nil
}
record, err := sys.getRecord()
if err != nil {
return err
}
sys.manager.hub.Logger().Error("System down", "system", record.GetString("name"), "err", OriginalError)
record.Set("status", down)
err = sys.manager.hub.SaveNoValidate(record)
if err != nil {
return err
}
return nil
}
// fetchDataFromAgent fetches the data from the agent.
// It first creates a new SSH client if it doesn't exist or the system is down.
// Then it creates a new SSH session and fetches the data from the agent.
// If the data is not found or the system is down, it sets the system down.
func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
maxRetries := 1
for attempt := 0; attempt <= maxRetries; attempt++ {
if sys.client == nil || sys.Status == down {
if err := sys.createSSHClient(); err != nil {
return nil, err
}
}
session, err := sys.createSessionWithTimeout(4 * time.Second)
if err != nil {
if attempt >= maxRetries {
return nil, err
}
sys.manager.hub.Logger().Warn("Session closed. Retrying...", "host", sys.Host, "port", sys.Port, "err", err)
sys.resetSSHClient()
continue
}
defer session.Close()
stdout, err := session.StdoutPipe()
if err != nil {
return nil, err
}
if err := session.Shell(); err != nil {
return nil, err
}
// this is initialized in startUpdater, should never be nil
*sys.data = system.CombinedData{}
if err := json.NewDecoder(stdout).Decode(sys.data); err != nil {
return nil, err
}
// wait for the session to complete
if err := session.Wait(); err != nil {
return nil, err
}
return sys.data, nil
}
// this should never be reached due to the return in the loop
return nil, fmt.Errorf("failed to fetch data")
}
func (sm *SystemManager) createSSHClientConfig(key []byte) error {
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
sm.sshConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: sessionTimeout,
}
return nil
}
// createSSHClient creates a new SSH client for the system
func (s *System) createSSHClient() error {
network := "tcp"
host := s.Host
if strings.HasPrefix(host, "/") {
network = "unix"
} else {
host = net.JoinHostPort(host, s.Port)
}
var err error
s.client, err = ssh.Dial(network, host, s.manager.sshConfig)
if err != nil {
return err
}
return nil
}
// createSessionWithTimeout creates a new SSH session with a timeout to avoid hanging
// in case of network issues
func (sys *System) createSessionWithTimeout(timeout time.Duration) (*ssh.Session, error) {
if sys.client == nil {
return nil, fmt.Errorf("client not initialized")
}
ctx, cancel := context.WithTimeout(sys.ctx, timeout)
defer cancel()
sessionChan := make(chan *ssh.Session, 1)
errChan := make(chan error, 1)
go func() {
if session, err := sys.client.NewSession(); err != nil {
errChan <- err
} else {
sessionChan <- session
}
}()
select {
case session := <-sessionChan:
return session, nil
case err := <-errChan:
return nil, err
case <-ctx.Done():
return nil, fmt.Errorf("timeout")
}
}
// resetSSHClient closes the SSH connection and resets the client to nil
func (sys *System) resetSSHClient() {
if sys.client != nil {
sys.client.Close()
}
sys.client = nil
}

View File

@@ -0,0 +1,440 @@
//go:build testing
// +build testing
package systems_test
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"beszel/internal/hub/systems"
"beszel/internal/tests"
"fmt"
"sync"
"testing"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// createTestSystem creates a test system record with a unique host name
// and returns the created record and any error
func createTestSystem(t *testing.T, hub *tests.TestHub, options map[string]any) (*core.Record, error) {
collection, err := hub.FindCachedCollectionByNameOrId("systems")
if err != nil {
return nil, err
}
// get user record
var firstUser *core.Record
users, err := hub.FindAllRecords("users", dbx.NewExp("id != ''"))
if err != nil {
t.Fatal(err)
}
if len(users) > 0 {
firstUser = users[0]
}
// Generate a unique host name to ensure we're adding a new system
uniqueHost := fmt.Sprintf("test-host-%d.example.com", time.Now().UnixNano())
// Create the record
record := core.NewRecord(collection)
record.Set("name", uniqueHost)
record.Set("host", uniqueHost)
record.Set("port", "45876")
record.Set("status", "pending")
record.Set("users", []string{firstUser.Id})
// Apply any custom options
for key, value := range options {
record.Set(key, value)
}
// Save the record to the database
err = hub.Save(record)
if err != nil {
return nil, err
}
return record, nil
}
func TestSystemManagerIntegration(t *testing.T) {
// Create a test hub
hub, err := tests.NewTestHub()
if err != nil {
t.Fatal(err)
}
defer hub.Cleanup()
// Create independent system manager
sm := systems.NewSystemManager(hub)
assert.NotNil(t, sm)
// Test initialization
sm.Initialize()
// Test collection existence. todo: move to hub package tests
t.Run("CollectionExistence", func(t *testing.T) {
// Verify that required collections exist
systems, err := hub.FindCachedCollectionByNameOrId("systems")
require.NoError(t, err)
assert.NotNil(t, systems)
systemStats, err := hub.FindCachedCollectionByNameOrId("system_stats")
require.NoError(t, err)
assert.NotNil(t, systemStats)
containerStats, err := hub.FindCachedCollectionByNameOrId("container_stats")
require.NoError(t, err)
assert.NotNil(t, containerStats)
})
// Test adding a system record
t.Run("AddRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// record should be pending on create
hub.OnRecordCreate("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "pending", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// record should be down on update
hub.OnRecordAfterUpdateSuccess("systems").BindFunc(func(e *core.RecordEvent) error {
record := e.Record
if record.GetString("name") == "welcometoarcoampm" {
assert.Equal(t, "down", e.Record.GetString("status"), "System status should be 'pending'")
wg.Done()
}
return e.Next()
})
// Create a test system with the first user assigned
record, err := createTestSystem(t, hub, map[string]any{
"name": "welcometoarcoampm",
"host": "localhost",
"port": "33914",
})
require.NoError(t, err)
wg.Wait()
// system should be down if grabbed from the store
assert.Equal(t, "down", sm.GetSystemStatusFromStore(record.Id), "System status should be 'down'")
// Check that the system count increased
countAfter := sm.GetSystemCount()
assert.Equal(t, countBefore+1, countAfter, "System count should increase after adding a system via event hook")
// Verify the system was added by checking if it exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// Verify the system host and port
host, port := sm.GetSystemHostPort(record.Id)
assert.Equal(t, record.Get("host"), host, "System host should match")
assert.Equal(t, record.Get("port"), port, "System port should match")
// Verify the system is in the list of all system IDs
ids := sm.GetAllSystemIDs()
assert.Contains(t, ids, record.Id, "System ID should be in the list of all system IDs")
// Verify the system was added by checking if removing it works
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err, "System should exist and be removable")
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
// Verify the system is not in the list of all system IDs
newIds := sm.GetAllSystemIDs()
assert.NotContains(t, newIds, record.Id, "System ID should not be in the list of all system IDs after removal")
})
t.Run("RemoveSystem", func(t *testing.T) {
// Get the count before adding the system
countBefore := sm.GetSystemCount()
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
require.NoError(t, err)
// Verify the system count increased
countAfterAdd := sm.GetSystemCount()
assert.Equal(t, countBefore+1, countAfterAdd, "System count should increase after adding a system via event hook")
// Verify the system exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// Remove the system
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err)
// Check that the system count decreased
countAfterRemove := sm.GetSystemCount()
assert.Equal(t, countAfterAdd-1, countAfterRemove, "System count should decrease after removing a system")
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after removal")
// Verify the system is not in the list of all system IDs
ids := sm.GetAllSystemIDs()
assert.NotContains(t, ids, record.Id, "System ID should not be in the list of all system IDs after removal")
// Verify the system status is empty
status := sm.GetSystemStatusFromStore(record.Id)
assert.Equal(t, "", status, "System status should be empty after removal")
// Try to remove it again - should return an error since it's already removed
err = sm.RemoveSystem(record.Id)
assert.Error(t, err)
})
t.Run("NewRecordPending", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
require.NoError(t, err)
// Add the record to the system manager
err = sm.AddRecord(record)
require.NoError(t, err)
// Test filtering records by status - should be "pending" now
filter := "status = 'pending'"
pendingSystems, err := hub.FindRecordsByFilter("systems", filter, "-created", 0, 0, nil)
require.NoError(t, err)
assert.GreaterOrEqual(t, len(pendingSystems), 1)
})
t.Run("SystemStatusUpdate", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
require.NoError(t, err)
// Add the record to the system manager
err = sm.AddRecord(record)
require.NoError(t, err)
// Test status changes
initialStatus := sm.GetSystemStatusFromStore(record.Id)
// Set a new status
sm.SetSystemStatusInDB(record.Id, "up")
// Verify status was updated
newStatus := sm.GetSystemStatusFromStore(record.Id)
assert.Equal(t, "up", newStatus, "System status should be updated to 'up'")
assert.NotEqual(t, initialStatus, newStatus, "Status should have changed")
// Verify the database was updated
updatedRecord, err := hub.FindRecordById("systems", record.Id)
require.NoError(t, err)
assert.Equal(t, "up", updatedRecord.Get("status"), "Database status should match")
})
t.Run("HandleSystemData", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
require.NoError(t, err)
// Create test system data
testData := &system.CombinedData{
Info: system.Info{
Hostname: "data-test.example.com",
KernelVersion: "5.15.0-generic",
Cores: 4,
Threads: 8,
CpuModel: "Test CPU",
Uptime: 3600,
Cpu: 25.5,
MemPct: 40.2,
DiskPct: 60.0,
Bandwidth: 100.0,
AgentVersion: "1.0.0",
},
Stats: system.Stats{
Cpu: 25.5,
Mem: 16384.0,
MemUsed: 6553.6,
MemPct: 40.0,
DiskTotal: 1024000.0,
DiskUsed: 614400.0,
DiskPct: 60.0,
NetworkSent: 1024.0,
NetworkRecv: 2048.0,
},
Containers: []*container.Stats{},
}
// Test handling system data. todo: move to hub/alerts package tests
err = hub.HandleSystemAlerts(record, testData)
assert.NoError(t, err)
})
t.Run("ErrorHandling", func(t *testing.T) {
// Try to add a non-existent record
nonExistentId := "non_existent_id"
err := sm.RemoveSystem(nonExistentId)
assert.Error(t, err)
// Try to add a system with invalid host
system := &systems.System{
Host: "",
}
err = sm.AddSystem(system)
assert.Error(t, err)
})
t.Run("DeleteRecord", func(t *testing.T) {
var wg sync.WaitGroup
wg.Add(2)
runs := 0
hub.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error {
runs++
record := e.Record
if record.GetString("name") == "deadflagblues" {
if runs == 1 {
assert.Equal(t, "up", e.Record.GetString("status"), "System status should be 'up'")
wg.Done()
} else if runs == 2 {
assert.Equal(t, "paused", e.Record.GetString("status"), "System status should be 'paused'")
wg.Done()
}
}
return e.Next()
})
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{
"name": "deadflagblues",
})
require.NoError(t, err)
// Verify the system exists
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// set the status manually to up
sm.SetSystemStatusInDB(record.Id, "up")
// verify the status is up
assert.Equal(t, "up", sm.GetSystemStatusFromStore(record.Id), "System status should be 'up'")
// Set the status to "paused" which should cause it to be deleted from the store
sm.SetSystemStatusInDB(record.Id, "paused")
wg.Wait()
// Verify the system no longer exists
assert.False(t, sm.HasSystem(record.Id), "System should not exist in the store after deletion")
})
t.Run("ConcurrentOperations", func(t *testing.T) {
// Create a test system
record, err := createTestSystem(t, hub, map[string]any{})
require.NoError(t, err)
// Run concurrent operations
const goroutines = 5
var wg sync.WaitGroup
wg.Add(goroutines)
for i := range goroutines {
go func(i int) {
defer wg.Done()
// Alternate between different operations
switch i % 3 {
case 0:
status := fmt.Sprintf("status-%d", i)
sm.SetSystemStatusInDB(record.Id, status)
case 1:
_ = sm.GetSystemStatusFromStore(record.Id)
case 2:
_, _ = sm.GetSystemHostPort(record.Id)
}
}(i)
}
wg.Wait()
// Verify system still exists and is in a valid state
assert.True(t, sm.HasSystem(record.Id), "System should still exist after concurrent operations")
status := sm.GetSystemStatusFromStore(record.Id)
assert.NotEmpty(t, status, "System should have a status after concurrent operations")
})
t.Run("ContextCancellation", func(t *testing.T) {
// Create a test system record
record, err := createTestSystem(t, hub, map[string]any{})
require.NoError(t, err)
// Verify the system exists in the store
assert.True(t, sm.HasSystem(record.Id), "System should exist in the store")
// Store the original context and cancel function
originalCtx, originalCancel, err := sm.GetSystemContextFromStore(record.Id)
assert.NoError(t, err)
// Ensure the context is not nil
assert.NotNil(t, originalCtx, "System context should not be nil")
assert.NotNil(t, originalCancel, "System cancel function should not be nil")
// Cancel the context
originalCancel()
// Wait a short time for cancellation to propagate
time.Sleep(10 * time.Millisecond)
// Verify the context is done
select {
case <-originalCtx.Done():
// Context was properly cancelled
default:
t.Fatal("Context was not cancelled")
}
// Verify the system is still in the store (cancellation shouldn't remove it)
assert.True(t, sm.HasSystem(record.Id), "System should still exist after context cancellation")
// Explicitly remove the system
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err, "RemoveSystem should succeed")
// Verify the system is removed
assert.False(t, sm.HasSystem(record.Id), "System should be removed after RemoveSystem")
// Try to remove it again - should return an error
err = sm.RemoveSystem(record.Id)
assert.Error(t, err, "RemoveSystem should fail for non-existent system")
// Add the system back
err = sm.AddRecord(record)
require.NoError(t, err, "AddRecord should succeed")
// Verify the system is back in the store
assert.True(t, sm.HasSystem(record.Id), "System should exist after re-adding")
// Verify a new context was created
newCtx, newCancel, err := sm.GetSystemContextFromStore(record.Id)
assert.NoError(t, err)
assert.NotNil(t, newCtx, "New system context should not be nil")
assert.NotNil(t, newCancel, "New system cancel function should not be nil")
assert.NotEqual(t, originalCtx, newCtx, "New context should be different from original")
// Clean up
err = sm.RemoveSystem(record.Id)
assert.NoError(t, err)
})
}

View File

@@ -0,0 +1,117 @@
//go:build testing
// +build testing
package systems
import (
entities "beszel/internal/entities/system"
"context"
"fmt"
)
// GetSystemCount returns the number of systems in the store
func (sm *SystemManager) GetSystemCount() int {
return sm.systems.Length()
}
// HasSystem checks if a system with the given ID exists in the store
func (sm *SystemManager) HasSystem(systemID string) bool {
return sm.systems.Has(systemID)
}
// GetSystemStatusFromStore returns the status of a system with the given ID
// Returns an empty string if the system doesn't exist
func (sm *SystemManager) GetSystemStatusFromStore(systemID string) string {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
return ""
}
return sys.Status
}
// GetSystemContextFromStore returns the context and cancel function for a system
func (sm *SystemManager) GetSystemContextFromStore(systemID string) (context.Context, context.CancelFunc, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
return nil, nil, fmt.Errorf("no system")
}
return sys.ctx, sys.cancel, nil
}
// GetSystemFromStore returns a store from the system
func (sm *SystemManager) GetSystemFromStore(systemID string) (*System, error) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
return nil, fmt.Errorf("no system")
}
return sys, nil
}
// GetAllSystemIDs returns a slice of all system IDs in the store
func (sm *SystemManager) GetAllSystemIDs() []string {
data := sm.systems.GetAll()
ids := make([]string, 0, len(data))
for id := range data {
ids = append(ids, id)
}
return ids
}
// GetSystemData returns the combined data for a system with the given ID
// Returns nil if the system doesn't exist
// This method is intended for testing
func (sm *SystemManager) GetSystemData(systemID string) *entities.CombinedData {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
return nil
}
return sys.data
}
// GetSystemHostPort returns the host and port for a system with the given ID
// Returns empty strings if the system doesn't exist
func (sm *SystemManager) GetSystemHostPort(systemID string) (string, string) {
sys, ok := sm.systems.GetOk(systemID)
if !ok {
return "", ""
}
return sys.Host, sys.Port
}
// DisableAutoUpdater disables the automatic updater for a system
// This is intended for testing
// Returns false if the system doesn't exist
// func (sm *SystemManager) DisableAutoUpdater(systemID string) bool {
// sys, ok := sm.systems.GetOk(systemID)
// if !ok {
// return false
// }
// if sys.cancel != nil {
// sys.cancel()
// sys.cancel = nil
// }
// return true
// }
// SetSystemStatusInDB sets the status of a system directly and updates the database record
// This is intended for testing
// Returns false if the system doesn't exist
func (sm *SystemManager) SetSystemStatusInDB(systemID string, status string) bool {
if !sm.HasSystem(systemID) {
return false
}
// Update the database record
record, err := sm.hub.FindRecordById("systems", systemID)
if err != nil {
return false
}
record.Set("status", status)
err = sm.hub.Save(record)
if err != nil {
return false
}
return true
}

View File

@@ -4,14 +4,15 @@ package records
import (
"beszel/internal/entities/container"
"beszel/internal/entities/system"
"fmt"
"log"
"math"
"strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
type RecordManager struct {
@@ -25,11 +26,6 @@ type LongerRecordData struct {
minShorterRecords int
}
type RecordDeletionData struct {
recordType string
retention time.Duration
}
type RecordStats []struct {
Stats []byte `db:"stats"`
}
@@ -39,7 +35,7 @@ func NewRecordManager(app core.App) *RecordManager {
}
// Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now()
longerRecordData := []LongerRecordData{
{
@@ -70,14 +66,24 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
}
// wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error {
activeSystems, err := txApp.FindAllRecords("systems", dbx.NewExp("status = 'up'"))
var err error
collections := [2]*core.Collection{}
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
if err != nil {
log.Println("failed to get active systems", "err", err.Error())
return err
}
collections[1], err = txApp.FindCachedCollectionByNameOrId("container_stats")
if err != nil {
return err
}
var systems []struct {
Id string `db:"id"`
}
txApp.DB().NewQuery("SELECT id FROM systems WHERE status='up'").All(&systems)
// loop through all active systems, time periods, and collections
for _, system := range activeSystems {
for _, system := range systems {
// log.Println("processing system", system.GetString("name"))
for i := range longerRecordData {
recordData := longerRecordData[i]
@@ -92,7 +98,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
if recordData.longerType != "10m" {
lastLongerRecord, err := txApp.FindFirstRecordByFilter(
collection.Id,
"type = {:type} && system = {:system} && created > {:created}",
"system = {:system} && type = {:type} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
)
// continue if longer record exists
@@ -108,7 +114,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
Select("stats").
From(collection.Name).
AndWhere(dbx.NewExp(
"type={:type} AND system={:system} AND created > {:created}",
"system={:system} AND type={:type} AND created > {:created}",
dbx.Params{
"type": recordData.shorterType,
"system": system.Id,
@@ -119,7 +125,6 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
// continue if not enough shorter records
if err != nil || len(stats) < recordData.minShorterRecords {
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
continue
}
// average the shorter records and create longer record
@@ -133,7 +138,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
longerRecord.Set("stats", rm.AverageContainerStats(stats))
}
if err := txApp.SaveNoValidate(longerRecord); err != nil {
log.Println("failed to save longer record", "err", err.Error())
log.Println("failed to save longer record", "err", err)
}
}
}
@@ -146,16 +151,20 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
}
// Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum := system.Stats{}
func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
sum := &system.Stats{}
count := float64(len(records))
// use different counter for temps in case some records don't have them
tempCount := float64(0)
var stats system.Stats
// Temporary struct for unmarshaling
stats := &system.Stats{}
// Accumulate totals
for i := range records {
stats = system.Stats{} // Zero the struct before unmarshalling
json.Unmarshal(records[i].Stats, &stats)
*stats = system.Stats{} // Reset tempStats for unmarshaling
if err := json.Unmarshal(records[i].Stats, stats); err != nil {
continue
}
sum.Cpu += stats.Cpu
sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed
@@ -171,26 +180,25 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv
// set peak values
// Set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
// add temps to sum
// Accumulate temperatures
if stats.Temperatures != nil {
if sum.Temperatures == nil {
sum.Temperatures = make(map[string]float64, len(stats.Temperatures))
}
tempCount++
for key, value := range stats.Temperatures {
if _, ok := sum.Temperatures[key]; !ok {
sum.Temperatures[key] = 0
}
sum.Temperatures[key] += value
}
}
// add extra fs to sum
// Accumulate extra filesystem stats
if stats.ExtraFs != nil {
if sum.ExtraFs == nil {
sum.ExtraFs = make(map[string]*system.FsStats, len(stats.ExtraFs))
@@ -199,25 +207,26 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
if _, ok := sum.ExtraFs[key]; !ok {
sum.ExtraFs[key] = &system.FsStats{}
}
sum.ExtraFs[key].DiskTotal += value.DiskTotal
sum.ExtraFs[key].DiskUsed += value.DiskUsed
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs
// peak values
sum.ExtraFs[key].MaxDiskReadPS = max(sum.ExtraFs[key].MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
fs := sum.ExtraFs[key]
fs.DiskTotal += value.DiskTotal
fs.DiskUsed += value.DiskUsed
fs.DiskWritePs += value.DiskWritePs
fs.DiskReadPs += value.DiskReadPs
fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
}
}
// add GPU data
// Accumulate GPU data
if stats.GPUData != nil {
if sum.GPUData == nil {
sum.GPUData = make(map[string]system.GPUData, len(stats.GPUData))
}
for id, value := range stats.GPUData {
if _, ok := sum.GPUData[id]; !ok {
sum.GPUData[id] = system.GPUData{Name: value.Name}
gpu, ok := sum.GPUData[id]
if !ok {
gpu = system.GPUData{Name: value.Name}
}
gpu := sum.GPUData[id]
gpu.Temperature += value.Temperature
gpu.MemoryUsed += value.MemoryUsed
gpu.MemoryTotal += value.MemoryTotal
@@ -229,76 +238,67 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
}
}
stats = system.Stats{
Cpu: twoDecimals(sum.Cpu / count),
Mem: twoDecimals(sum.Mem / count),
MemUsed: twoDecimals(sum.MemUsed / count),
MemPct: twoDecimals(sum.MemPct / count),
MemBuffCache: twoDecimals(sum.MemBuffCache / count),
MemZfsArc: twoDecimals(sum.MemZfsArc / count),
Swap: twoDecimals(sum.Swap / count),
SwapUsed: twoDecimals(sum.SwapUsed / count),
DiskTotal: twoDecimals(sum.DiskTotal / count),
DiskUsed: twoDecimals(sum.DiskUsed / count),
DiskPct: twoDecimals(sum.DiskPct / count),
DiskReadPs: twoDecimals(sum.DiskReadPs / count),
DiskWritePs: twoDecimals(sum.DiskWritePs / count),
NetworkSent: twoDecimals(sum.NetworkSent / count),
NetworkRecv: twoDecimals(sum.NetworkRecv / count),
MaxCpu: sum.MaxCpu,
MaxDiskReadPs: sum.MaxDiskReadPs,
MaxDiskWritePs: sum.MaxDiskWritePs,
MaxNetworkSent: sum.MaxNetworkSent,
MaxNetworkRecv: sum.MaxNetworkRecv,
}
// Compute averages in place
if count > 0 {
sum.Cpu = twoDecimals(sum.Cpu / count)
sum.Mem = twoDecimals(sum.Mem / count)
sum.MemUsed = twoDecimals(sum.MemUsed / count)
sum.MemPct = twoDecimals(sum.MemPct / count)
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
sum.Swap = twoDecimals(sum.Swap / count)
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
sum.DiskPct = twoDecimals(sum.DiskPct / count)
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
if sum.Temperatures != nil {
stats.Temperatures = make(map[string]float64, len(sum.Temperatures))
for key, value := range sum.Temperatures {
stats.Temperatures[key] = twoDecimals(value / tempCount)
// Average temperatures
if sum.Temperatures != nil && tempCount > 0 {
for key := range sum.Temperatures {
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
}
}
}
if sum.ExtraFs != nil {
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs))
for key, value := range sum.ExtraFs {
stats.ExtraFs[key] = &system.FsStats{
DiskTotal: twoDecimals(value.DiskTotal / count),
DiskUsed: twoDecimals(value.DiskUsed / count),
DiskWritePs: twoDecimals(value.DiskWritePs / count),
DiskReadPs: twoDecimals(value.DiskReadPs / count),
MaxDiskReadPS: value.MaxDiskReadPS,
MaxDiskWritePS: value.MaxDiskWritePS,
// 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)
}
}
// 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)
sum.GPUData[id] = gpu
}
}
}
if sum.GPUData != nil {
stats.GPUData = make(map[string]system.GPUData, len(sum.GPUData))
for id, value := range sum.GPUData {
stats.GPUData[id] = system.GPUData{
Name: value.Name,
Temperature: twoDecimals(value.Temperature / count),
MemoryUsed: twoDecimals(value.MemoryUsed / count),
MemoryTotal: twoDecimals(value.MemoryTotal / count),
Usage: twoDecimals(value.Usage / count),
Power: twoDecimals(value.Power / count),
Count: twoDecimals(value.Count / count),
}
}
}
return stats
return sum
}
// Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
sums := make(map[string]*container.Stats)
count := float64(len(records))
var containerStats []container.Stats
containerStats := make([]container.Stats, 0, 50)
for i := range records {
// Reset the slice length to 0, but keep the capacity
// reset slice
containerStats = containerStats[:0]
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
return []container.Stats{}
@@ -330,38 +330,45 @@ func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.
// Deletes records older than what is displayed in the UI
func (rm *RecordManager) DeleteOldRecords() {
// Define the collections to process
collections := []string{"system_stats", "container_stats"}
recordData := []RecordDeletionData{
{
recordType: "1m",
retention: time.Hour,
},
{
recordType: "10m",
retention: 12 * time.Hour,
},
{
recordType: "20m",
retention: 24 * time.Hour,
},
{
recordType: "120m",
retention: 7 * 24 * time.Hour,
},
{
recordType: "480m",
retention: 30 * 24 * time.Hour,
},
// Define record types and their retention periods
type RecordDeletionData struct {
recordType string
retention time.Duration
}
db := rm.app.NonconcurrentDB()
for _, recordData := range recordData {
for _, collectionSlug := range collections {
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout)
expr := dbx.NewExp("[[created]] < {:date} AND [[type]] = {:type}", dbx.Params{"date": formattedDate, "type": recordData.recordType})
_, err := db.Delete(collectionSlug, expr).Execute()
if err != nil {
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
}
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
}
// Process each collection
for _, collection := range collections {
// Build the WHERE clause dynamically
var conditionParts []string
var params dbx.Params = make(map[string]any)
for i, rd := range recordData {
// 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] = time.Now().UTC().Add(-rd.retention)
}
// Combine conditions with OR
conditionStr := strings.Join(conditionParts, " OR ")
// Construct the full raw query
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
// Execute the query with parameters
if _, err := rm.app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
// return fmt.Errorf("failed to delete from %s: %v", collection, err)
rm.app.Logger().Error("failed to delete", "collection", collection, "error", err)
}
}
}

View File

@@ -0,0 +1,58 @@
// Package tests provides helpers for testing the application.
package tests
import (
"beszel/internal/hub"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tests"
_ "github.com/pocketbase/pocketbase/migrations"
)
// TestHub is a wrapper hub instance used for testing.
type TestHub struct {
core.App
*tests.TestApp
*hub.Hub
}
// NewTestHub creates and initializes a test application instance.
//
// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed.
func NewTestHub(optTestDataDir ...string) (*TestHub, error) {
var testDataDir string
if len(optTestDataDir) > 0 {
testDataDir = optTestDataDir[0]
}
return NewTestHubWithConfig(core.BaseAppConfig{
DataDir: testDataDir,
EncryptionEnv: "pb_test_env",
})
}
// NewTestHubWithConfig creates and initializes a test application instance
// from the provided config.
//
// If config.DataDir is not set it fallbacks to the default internal test data directory.
//
// config.DataDir is cloned for each new test application instance.
//
// It is the caller's responsibility to call app.Cleanup() when the app is no longer needed.
func NewTestHubWithConfig(config core.BaseAppConfig) (*TestHub, error) {
testApp, err := tests.NewTestAppWithConfig(config)
if err != nil {
return nil, err
}
hub := hub.NewHub(testApp)
t := &TestHub{
App: testApp,
TestApp: testApp,
Hub: hub,
}
return t, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,98 +0,0 @@
package migrations
import (
"encoding/json"
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("_pb_users_auth_")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__email_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''",
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__tokenKey_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `tokenKey` + "`" + `)"
]
}`), &collection); err != nil {
return err
}
// remove field
collection.Fields.RemoveById("text4166911607")
// update field
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
}`)); err != nil {
return err
}
return app.Save(collection)
}, func(app core.App) error {
collection, err := app.FindCollectionByNameOrId("_pb_users_auth_")
if err != nil {
return err
}
// update collection data
if err := json.Unmarshal([]byte(`{
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__username_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (username COLLATE NOCASE)",
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__email_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''",
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__tokenKey_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `tokenKey` + "`" + `)"
]
}`), &collection); err != nil {
return err
}
// add field
if err := collection.Fields.AddMarshaledJSONAt(6, []byte(`{
"autogeneratePattern": "users[0-9]{6}",
"hidden": false,
"id": "text4166911607",
"max": 150,
"min": 3,
"name": "username",
"pattern": "^[\\w][\\w\\.\\-]*$",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
}`)); err != nil {
return err
}
// update field
if err := collection.Fields.AddMarshaledJSONAt(3, []byte(`{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": false,
"system": true,
"type": "email"
}`)); err != nil {
return err
}
return app.Save(collection)
})
}

View File

@@ -0,0 +1,665 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
jsonData := `[
{
"id": "elngm8x1l60zi2v",
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": "",
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"name": "alerts",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "hn5ly3vi",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "g5sl3jdg",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "zj3ingrv",
"maxSelect": 1,
"name": "name",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"Status",
"CPU",
"Memory",
"Disk",
"Temperature",
"Bandwidth"
]
},
{
"hidden": false,
"id": "o2ablxvn",
"max": null,
"min": null,
"name": "value",
"onlyInt": false,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "fstdehcq",
"max": 60,
"min": null,
"name": "min",
"onlyInt": true,
"presentable": false,
"required": false,
"system": false,
"type": "number"
},
{
"hidden": false,
"id": "6hgdf6hs",
"name": "triggered",
"presentable": false,
"required": false,
"system": false,
"type": "bool"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_MnhEt21L5r` + "`" + ` ON ` + "`" + `alerts` + "`" + ` (` + "`" + `system` + "`" + `)"
],
"system": false
},
{
"id": "juohu4jipgc13v7",
"listRule": "@request.auth.id != \"\"",
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "container_stats",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "hutcu6ps",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "r39hhnil",
"maxSize": 2000000,
"name": "stats",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "vo7iuj96",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"1m",
"10m",
"20m",
"120m",
"480m"
]
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_d87OiXGZD8` + "`" + ` ON ` + "`" + `container_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
],
"system": false
},
{
"id": "ej9oowivz8b2mht",
"listRule": "@request.auth.id != \"\"",
"viewRule": null,
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "system_stats",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "2hz5ncl8tizk5nx",
"hidden": false,
"id": "h9sg148r",
"maxSelect": 1,
"minSelect": 0,
"name": "system",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "azftn0be",
"maxSize": 2000000,
"name": "stats",
"presentable": false,
"required": true,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "m1ekhli3",
"maxSelect": 1,
"name": "type",
"presentable": false,
"required": true,
"system": false,
"type": "select",
"values": [
"1m",
"10m",
"20m",
"120m",
"480m"
]
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE INDEX ` + "`" + `idx_GxIee0j` + "`" + ` ON ` + "`" + `system_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
],
"system": false
},
{
"id": "4afacsdnlu8q8r2",
"listRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"viewRule": null,
"createRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"updateRule": "@request.auth.id != \"\" && user.id = @request.auth.id",
"deleteRule": null,
"name": "user_settings",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "d5vztyxa",
"maxSelect": 1,
"minSelect": 0,
"name": "user",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "xcx4qgqq",
"maxSize": 2000000,
"name": "settings",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `idx_30Lwgf2` + "`" + ` ON ` + "`" + `user_settings` + "`" + ` (` + "`" + `user` + "`" + `)"
],
"system": false
},
{
"id": "2hz5ncl8tizk5nx",
"listRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
"viewRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id",
"createRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"updateRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"deleteRule": "@request.auth.id != \"\" && users.id ?= @request.auth.id && @request.auth.role != \"readonly\"",
"name": "systems",
"type": "base",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "7xloxkwk",
"max": 0,
"min": 0,
"name": "name",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "waj7seaf",
"maxSelect": 1,
"name": "status",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"up",
"down",
"paused",
"pending"
]
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "ve781smf",
"max": 0,
"min": 0,
"name": "host",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": false,
"type": "text"
},
{
"autogeneratePattern": "",
"hidden": false,
"id": "pij0k2jk",
"max": 0,
"min": 0,
"name": "port",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "qoq64ntl",
"maxSize": 2000000,
"name": "info",
"presentable": false,
"required": false,
"system": false,
"type": "json"
},
{
"cascadeDelete": true,
"collectionId": "_pb_users_auth_",
"hidden": false,
"id": "jcarjnjj",
"maxSelect": 2147483647,
"minSelect": 0,
"name": "users",
"presentable": false,
"required": true,
"system": false,
"type": "relation"
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [],
"system": false
},
{
"id": "_pb_users_auth_",
"listRule": "id = @request.auth.id",
"viewRule": "id = @request.auth.id",
"createRule": null,
"updateRule": null,
"deleteRule": null,
"name": "users",
"type": "auth",
"fields": [
{
"autogeneratePattern": "[a-z0-9]{15}",
"hidden": false,
"id": "text3208210256",
"max": 15,
"min": 15,
"name": "id",
"pattern": "^[a-z0-9]+$",
"presentable": false,
"primaryKey": true,
"required": true,
"system": true,
"type": "text"
},
{
"cost": 10,
"hidden": true,
"id": "password901924565",
"max": 0,
"min": 8,
"name": "password",
"pattern": "",
"presentable": false,
"required": true,
"system": true,
"type": "password"
},
{
"autogeneratePattern": "[a-zA-Z0-9_]{50}",
"hidden": true,
"id": "text2504183744",
"max": 60,
"min": 30,
"name": "tokenKey",
"pattern": "",
"presentable": false,
"primaryKey": false,
"required": true,
"system": true,
"type": "text"
},
{
"exceptDomains": null,
"hidden": false,
"id": "email3885137012",
"name": "email",
"onlyDomains": null,
"presentable": false,
"required": true,
"system": true,
"type": "email"
},
{
"hidden": false,
"id": "bool1547992806",
"name": "emailVisibility",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"hidden": false,
"id": "bool256245529",
"name": "verified",
"presentable": false,
"required": false,
"system": true,
"type": "bool"
},
{
"autogeneratePattern": "users[0-9]{6}",
"hidden": false,
"id": "text4166911607",
"max": 150,
"min": 3,
"name": "username",
"pattern": "^[\\w][\\w\\.\\-]*$",
"presentable": false,
"primaryKey": false,
"required": false,
"system": false,
"type": "text"
},
{
"hidden": false,
"id": "qkbp58ae",
"maxSelect": 1,
"name": "role",
"presentable": false,
"required": false,
"system": false,
"type": "select",
"values": [
"user",
"admin",
"readonly"
]
},
{
"hidden": false,
"id": "autodate2990389176",
"name": "created",
"onCreate": true,
"onUpdate": false,
"presentable": false,
"system": false,
"type": "autodate"
},
{
"hidden": false,
"id": "autodate3332085495",
"name": "updated",
"onCreate": true,
"onUpdate": true,
"presentable": false,
"system": false,
"type": "autodate"
}
],
"indexes": [
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__username_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (username COLLATE NOCASE)",
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__email_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `email` + "`" + `) WHERE ` + "`" + `email` + "`" + ` != ''",
"CREATE UNIQUE INDEX ` + "`" + `__pb_users_auth__tokenKey_idx` + "`" + ` ON ` + "`" + `users` + "`" + ` (` + "`" + `tokenKey` + "`" + `)"
],
"system": false,
"authRule": "verified=true",
"manageRule": null
}
]`
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
}, func(app core.App) error {
return nil
})
}

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@@ -31,35 +31,35 @@
"@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-table": "^8.20.6",
"@tanstack/react-table": "^8.21.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.0.4",
"d3-time": "^3.1.0",
"lucide-react": "^0.452.0",
"nanostores": "^0.11.3",
"pocketbase": "^0.25.1",
"nanostores": "^0.11.4",
"pocketbase": "^0.25.2",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.1",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"valibot": "^0.36.0"
"valibot": "^0.42.0"
},
"devDependencies": {
"@lingui/cli": "^4.14.1",
"@lingui/swc-plugin": "^4.1.0",
"@lingui/vite-plugin": "^4.14.1",
"@types/bun": "^1.2.2",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitejs/plugin-react-swc": "^3.7.2",
"@lingui/cli": "^5.2.0",
"@lingui/swc-plugin": "^5.4.0",
"@lingui/vite-plugin": "^5.2.0",
"@types/bun": "^1.2.4",
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0",
"typescript": "^5.7.3",
"vite": "^5.4.14"
"typescript": "^5.8.2",
"vite": "^6.2.0"
},
"overrides": {
"@nanostores/router": {

View File

@@ -14,7 +14,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { $publicKey, pb } from "@/lib/stores"
import { cn, copyToClipboard, isReadOnlyUser } from "@/lib/utils"
import { cn, copyToClipboard, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import { i18n } from "@lingui/core"
import { t, Trans } from "@lingui/macro"
import { useStore } from "@nanostores/react"
@@ -61,13 +61,13 @@ function copyDockerCompose(port = "45876", publicKey: string) {
# monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment:
PORT: ${port}
LISTEN: ${port}
KEY: "${publicKey}"`)
}
function copyDockerRun(port = "45876", publicKey: string) {
copyToClipboard(
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e PORT=${port} henrygd/beszel-agent:latest`
`docker run -d --name beszel-agent --network host --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock:ro -e KEY="${publicKey}" -e LISTEN=${port} henrygd/beszel-agent:latest`
)
}
@@ -91,6 +91,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
const port = useRef<HTMLInputElement>(null)
const [hostValue, setHostValue] = useState(system?.host ?? "")
const isUnixSocket = hostValue.startsWith("/")
const [tab, setTab] = useLocalStorage("as-tab", "docker")
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -118,7 +119,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
setHostValue(system?.host ?? "")
}}
>
<Tabs defaultValue="docker">
<Tabs defaultValue={tab} onValueChange={setTab}>
<DialogHeader>
<DialogTitle className="mb-2">
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
@@ -140,7 +141,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</DialogDescription>
</TabsContent>
{/* Binary */}
<TabsContent value="binary">
<TabsContent value="binary" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<Trans>
The agent must be running on the system to connect. Copy the installation command for the agent below.
@@ -259,12 +260,12 @@ const CopyButton = memo((props: CopyButtonProps) => {
<DropdownMenuContent align="end">
{props.dropdownUrl ? (
<DropdownMenuItem asChild>
<a href={props.dropdownUrl} target="_blank" rel="noopener noreferrer">
<a href={props.dropdownUrl} className="cursor-pointer" target="_blank" rel="noopener noreferrer">
{props.dropdownText}
</a>
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={props.dropdownOnClick}>{props.dropdownText}</DropdownMenuItem>
<DropdownMenuItem onClick={props.dropdownOnClick} className="cursor-pointer">{props.dropdownText}</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@@ -229,7 +229,7 @@ function AlertContent({ data }: { data: AlertData }) {
)}
<Trans>
For <strong className="text-foreground">{min}</strong>{" "}
<Plural value={min} one=" minute" other=" minutes" />
<Plural value={min} one="minute" other="minutes" />
</Trans>
</p>
<div className="flex gap-3">

View File

@@ -22,7 +22,7 @@ import {
import { useEffect } from "react"
import { useStore } from "@nanostores/react"
import { $systems } from "@/lib/stores"
import { getHostDisplayValue, isAdmin } from "@/lib/utils"
import { getHostDisplayValue, isAdmin, listen } from "@/lib/utils"
import { $router, basePath, navigate } from "./router"
import { Trans, t } from "@lingui/macro"
import { getPagePath } from "@nanostores/router"
@@ -37,9 +37,7 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
setOpen(!open)
}
}
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
return listen(document, "keydown", down)
}, [open, setOpen])
return (

View File

@@ -135,7 +135,6 @@ export function UserAuthForm({
toast({
title: t`Error`,
description: t`Please enable pop-ups for this site`,
variant: "destructive",
})
return
}
@@ -156,8 +155,11 @@ export function UserAuthForm({
useEffect(() => {
// auto login if password disabled and only one auth provider
if (!passwordEnabled && authProviders.length === 1) {
loginWithOauth(authProviders[0], true)
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
// Add a small timeout to ensure browser is ready to handle popups
setTimeout(() => {
loginWithOauth(authProviders[0], true)
}, 300)
}
}, [])

View File

@@ -12,6 +12,7 @@ import {
getHostDisplayValue,
getPbTimestamp,
getSizeAndUnit,
listen,
toFixedFloat,
useLocalStorage,
} from "@/lib/utils"
@@ -25,6 +26,8 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from ".
import { timeTicks } from "d3-time"
import { Plural, Trans, t } from "@lingui/macro"
import { useLingui } from "@lingui/react"
import { $router, navigate } from "../router"
import { getPagePath } from "@nanostores/router"
const AreaChartDefault = lazy(() => import("../charts/area-chart"))
const ContainerChart = lazy(() => import("../charts/container-chart"))
@@ -112,6 +115,7 @@ export default function SystemDetail({ name }: { name: string }) {
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const netCardRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true)
@@ -120,8 +124,10 @@ export default function SystemDetail({ name }: { name: string }) {
useEffect(() => {
document.title = `${name} / Beszel`
return () => {
if (!persistChartTime.current) {
$chartTime.set($userSettings.get().chartTime)
// resetCharts()
}
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setContainerFilterBar(null)
@@ -260,7 +266,7 @@ export default function SystemDetail({ name }: { name: string }) {
// hide if hostname is same as host or name
hide: system.info.h === system.host || system.info.h === system.name,
},
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime` },
{ value: uptime, Icon: ClockArrowUp, label: t`Uptime`, hide: !system.info.u },
{ value: system.info.k, Icon: TuxIcon, label: t({ comment: "Linux kernel", message: "Kernel" }) },
{
value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
@@ -288,6 +294,35 @@ export default function SystemDetail({ name }: { name: string }) {
const distanceToBottom = wrapperRect.bottom - chartRect.bottom
setBottomSpacing(tooltipHeight - distanceToBottom)
}, [netCardRef, containerData])
// keyboard navigation between systems
useEffect(() => {
if (!systems.length) {
return
}
const handleKeyUp = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
const currentIndex = systems.findIndex(s => s.name === name)
if (currentIndex === -1 || systems.length <= 1) {
return
}
switch (e.key) {
case "ArrowLeft":
case "h":
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
persistChartTime.current = true
return navigate(getPagePath($router, "system", { name: systems[prevIndex].name}))
case "ArrowRight":
case "l":
const nextIndex = (currentIndex + 1) % systems.length
persistChartTime.current = true
return navigate(getPagePath($router, "system", { name: systems[nextIndex].name}))
}
}
return listen(document, "keyup", handleKeyUp)
}, [name, systems])
if (!system.id) {
return null

View File

@@ -135,6 +135,12 @@ export default function SystemsTable() {
}, [filter])
const columns = useMemo(() => {
// Create status translations for filtering
const statusTranslations = {
"up": t`Up`.toLowerCase(),
"down": t`Down`.toLowerCase(),
"paused": t`Paused`.toLowerCase()
};
return [
{
// size: 200,
@@ -142,6 +148,15 @@ export default function SystemsTable() {
minSize: 0,
accessorKey: "name",
id: t`System`,
filterFn: (row, _, filterVal) => {
const filterLower = filterVal.toLowerCase();
const { name, status } = row.original;
// Check if the filter matches the name or status for this row
if (name.toLowerCase().includes(filterLower) || statusTranslations[status as keyof typeof statusTranslations]?.includes(filterLower)) {
return true;
}
return false;
},
enableHiding: false,
icon: ServerIcon,
cell: (info) => (

View File

@@ -15,7 +15,16 @@ import { prependBasePath } from "@/components/router"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// export const cn = clsx
/** Adds event listener to node and returns function that removes the listener */
export function listen<T extends Event = Event>(
node: Node,
event: string,
handler: (event: T) => void
) {
node.addEventListener(event, handler as EventListener)
return () => node.removeEventListener(event, handler as EventListener)
}
export async function copyToClipboard(content: string) {
const duration = 1500
@@ -68,6 +77,7 @@ export const updateSystemList = (() => {
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() {
sessionStorage.setItem("lo", "t")
pb.authStore.clear()
pb.realtime.unsubscribe()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -13,39 +13,40 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#: src/components/routes/system.tsx:252
#. placeholder {0}: Math.trunc(system.info?.u / 86400)
#: src/components/routes/system.tsx:258
msgid "{0, plural, one {# day} other {# days}}"
msgstr "{0, plural, one {# day} other {# days}}"
#: src/components/routes/system.tsx:250
#: src/components/routes/system.tsx:256
msgid "{hours, plural, one {# hour} other {# hours}}"
msgstr "{hours, plural, one {# hour} other {# hours}}"
#: src/lib/utils.ts:158
#: src/lib/utils.ts:168
msgid "1 hour"
msgstr "1 hour"
#: src/lib/utils.ts:181
#: src/lib/utils.ts:191
msgid "1 week"
msgstr "1 week"
#: src/lib/utils.ts:166
#: src/lib/utils.ts:176
msgid "12 hours"
msgstr "12 hours"
#: src/lib/utils.ts:174
#: src/lib/utils.ts:184
msgid "24 hours"
msgstr "24 hours"
#: src/lib/utils.ts:189
#: src/lib/utils.ts:199
msgid "30 days"
msgstr "30 days"
#. Table column
#: src/components/systems-table/systems-table.tsx:278
#: src/components/systems-table/systems-table.tsx:366
#: src/components/systems-table/systems-table.tsx:508
#: src/components/systems-table/systems-table.tsx:518
#: src/components/systems-table/systems-table.tsx:293
#: src/components/systems-table/systems-table.tsx:381
#: src/components/systems-table/systems-table.tsx:523
#: src/components/systems-table/systems-table.tsx:533
msgid "Actions"
msgstr "Actions"
@@ -57,11 +58,11 @@ msgstr "Active Alerts"
msgid "Add <0>System</0>"
msgstr "Add <0>System</0>"
#: src/components/add-system.tsx:124
#: src/components/add-system.tsx:125
msgid "Add New System"
msgstr "Add New System"
#: src/components/add-system.tsx:230
#: src/components/add-system.tsx:231
msgid "Add system"
msgstr "Add system"
@@ -73,15 +74,15 @@ msgstr "Add URL"
msgid "Adjust display options for charts."
msgstr "Adjust display options for charts."
#: src/components/command-palette.tsx:133
#: src/components/command-palette.tsx:146
#: src/components/command-palette.tsx:160
#: src/components/command-palette.tsx:174
#: src/components/command-palette.tsx:189
#: src/components/command-palette.tsx:131
#: src/components/command-palette.tsx:144
#: src/components/command-palette.tsx:158
#: src/components/command-palette.tsx:172
#: src/components/command-palette.tsx:187
msgid "Admin"
msgstr "Admin"
#: src/components/systems-table/systems-table.tsx:246
#: src/components/systems-table/systems-table.tsx:261
msgid "Agent"
msgstr "Agent"
@@ -90,12 +91,12 @@ msgstr "Agent"
msgid "Alerts"
msgstr "Alerts"
#: src/components/systems-table/systems-table.tsx:334
#: src/components/alerts/alert-button.tsx:88
#: src/components/systems-table/systems-table.tsx:319
msgid "All Systems"
msgstr "All Systems"
#: src/components/systems-table/systems-table.tsx:642
#: src/components/systems-table/systems-table.tsx:657
msgid "Are you sure you want to delete {name}?"
msgstr "Are you sure you want to delete {name}?"
@@ -103,41 +104,43 @@ msgstr "Are you sure you want to delete {name}?"
msgid "Automatic copy requires a secure context."
msgstr "Automatic copy requires a secure context."
#: src/components/routes/system.tsx:633
#: src/components/routes/system.tsx:668
msgid "Average"
msgstr "Average"
#: src/components/routes/system.tsx:410
#: src/components/routes/system.tsx:445
msgid "Average CPU utilization of containers"
msgstr "Average CPU utilization of containers"
#. placeholder {0}: data.alert.unit
#: src/components/alerts/alerts-system.tsx:205
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Average exceeds <0>{value}{0}</0>"
#: src/components/routes/system.tsx:511
#: src/components/routes/system.tsx:546
msgid "Average power consumption of GPUs"
msgstr "Average power consumption of GPUs"
#: src/components/routes/system.tsx:399
#: src/components/routes/system.tsx:434
msgid "Average system-wide CPU utilization"
msgstr "Average system-wide CPU utilization"
#: src/components/routes/system.tsx:529
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx:564
msgid "Average utilization of {0}"
msgstr "Average utilization of {0}"
#: src/components/command-palette.tsx:171
#: src/components/navbar.tsx:94
#: src/components/command-palette.tsx:169
msgid "Backups"
msgstr "Backups"
#: src/components/routes/system.tsx:455
#: src/lib/utils.ts:327
#: src/lib/utils.ts:337
#: src/components/routes/system.tsx:490
msgid "Bandwidth"
msgstr "Bandwidth"
#: src/components/login/auth-form.tsx:304
#: src/components/login/auth-form.tsx:306
msgid "Beszel supports OpenID Connect and many OAuth2 authentication providers."
msgstr "Beszel supports OpenID Connect and many OAuth2 authentication providers."
@@ -145,7 +148,7 @@ msgstr "Beszel supports OpenID Connect and many OAuth2 authentication providers.
msgid "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
msgstr "Beszel uses <0>Shoutrrr</0> to integrate with popular notification services."
#: src/components/add-system.tsx:129
#: src/components/add-system.tsx:130
msgid "Binary"
msgstr "Binary"
@@ -153,7 +156,7 @@ msgstr "Binary"
msgid "Cache / Buffers"
msgstr "Cache / Buffers"
#: src/components/systems-table/systems-table.tsx:653
#: src/components/systems-table/systems-table.tsx:668
msgid "Cancel"
msgstr "Cancel"
@@ -181,7 +184,7 @@ msgstr "Check logs for more details."
msgid "Check your notification service"
msgstr "Check your notification service"
#: src/components/add-system.tsx:203
#: src/components/add-system.tsx:204
msgid "Click to copy"
msgstr "Click to copy"
@@ -194,29 +197,29 @@ msgstr "Command line instructions"
msgid "Configure how you receive alert notifications."
msgstr "Configure how you receive alert notifications."
#: src/components/login/auth-form.tsx:210
#: src/components/login/auth-form.tsx:215
#: src/components/login/auth-form.tsx:212
#: src/components/login/auth-form.tsx:217
msgid "Confirm password"
msgstr "Confirm password"
#: src/components/systems-table/systems-table.tsx:659
#: src/components/systems-table/systems-table.tsx:674
msgid "Continue"
msgstr "Continue"
#: src/lib/utils.ts:26
#: src/lib/utils.ts:35
msgid "Copied to clipboard"
msgstr "Copied to clipboard"
#: src/components/add-system.tsx:214
#: src/components/add-system.tsx:216
#: src/components/add-system.tsx:215
#: src/components/add-system.tsx:217
msgid "Copy"
msgstr "Copy"
#: src/components/systems-table/systems-table.tsx:624
#: src/components/systems-table/systems-table.tsx:639
msgid "Copy host"
msgstr "Copy host"
#: src/components/add-system.tsx:223
#: src/components/add-system.tsx:224
msgid "Copy Linux command"
msgstr "Copy Linux command"
@@ -224,17 +227,17 @@ msgstr "Copy Linux command"
msgid "Copy text"
msgstr "Copy text"
#: src/components/systems-table/systems-table.tsx:165
#: src/components/systems-table/systems-table.tsx:180
msgid "CPU"
msgstr "CPU"
#: src/lib/utils.ts:319
#: src/components/routes/system.tsx:433
#: src/components/charts/area-chart.tsx:56
#: src/components/routes/system.tsx:398
#: src/lib/utils.ts:309
msgid "CPU Usage"
msgstr "CPU Usage"
#: src/components/login/auth-form.tsx:236
#: src/components/login/auth-form.tsx:238
msgid "Create account"
msgstr "Create account"
@@ -243,7 +246,7 @@ msgstr "Create account"
msgid "Dark"
msgstr "Dark"
#: src/components/command-palette.tsx:82
#: src/components/command-palette.tsx:80
#: src/components/routes/home.tsx:35
msgid "Dashboard"
msgstr "Dashboard"
@@ -252,57 +255,58 @@ msgstr "Dashboard"
msgid "Default time period"
msgstr "Default time period"
#: src/components/systems-table/systems-table.tsx:629
#: src/components/systems-table/systems-table.tsx:644
msgid "Delete"
msgstr "Delete"
#: src/components/systems-table/systems-table.tsx:181
#: src/components/systems-table/systems-table.tsx:196
msgid "Disk"
msgstr "Disk"
#: src/components/routes/system.tsx:445
#: src/components/routes/system.tsx:480
msgid "Disk I/O"
msgstr "Disk I/O"
#: src/lib/utils.ts:331
#: src/components/routes/system.tsx:473
#: src/components/charts/disk-chart.tsx:79
#: src/components/routes/system.tsx:438
#: src/lib/utils.ts:321
msgid "Disk Usage"
msgstr "Disk Usage"
#: src/components/routes/system.tsx:566
#: src/components/routes/system.tsx:601
msgid "Disk usage of {extraFsName}"
msgstr "Disk usage of {extraFsName}"
#: src/components/routes/system.tsx:409
#: src/components/routes/system.tsx:444
msgid "Docker CPU Usage"
msgstr "Docker CPU Usage"
#: src/components/routes/system.tsx:430
#: src/components/routes/system.tsx:465
msgid "Docker Memory Usage"
msgstr "Docker Memory Usage"
#: src/components/routes/system.tsx:471
#: src/components/routes/system.tsx:506
msgid "Docker Network I/O"
msgstr "Docker Network I/O"
#: src/components/command-palette.tsx:125
#: src/components/command-palette.tsx:123
msgid "Documentation"
msgstr "Documentation"
#. Context: System is down
#: src/components/routes/system.tsx:309
#: src/lib/utils.ts:306
#: src/lib/utils.ts:316
#: src/components/systems-table/systems-table.tsx:141
#: src/components/routes/system.tsx:344
msgid "Down"
msgstr "Down"
#: src/components/add-system.tsx:124
#: src/components/systems-table/systems-table.tsx:599
#: src/components/add-system.tsx:125
#: src/components/systems-table/systems-table.tsx:614
msgid "Edit"
msgstr "Edit"
#: src/components/login/auth-form.tsx:173
#: src/components/login/forgot-pass-form.tsx:53
#: src/components/login/auth-form.tsx:175
msgid "Email"
msgstr "Email"
@@ -318,12 +322,15 @@ msgstr "Enter email address to reset password"
msgid "Enter email address..."
msgstr "Enter email address..."
#: src/components/login/auth-form.tsx:136
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/routes/settings/notifications.tsx:188
#: src/components/routes/settings/config-yaml.tsx:28
#: src/components/login/auth-form.tsx:136
msgid "Error"
msgstr "Error"
#. placeholder {0}: alert.value
#. placeholder {1}: info.unit
#. placeholder {2}: alert.min
#: src/components/routes/home.tsx:81
msgid "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
msgstr "Exceeds {0}{1} in last {2, plural, one {# minute} other {# minutes}}"
@@ -340,12 +347,12 @@ msgstr "Export configuration"
msgid "Export your current systems configuration."
msgstr "Export your current systems configuration."
#: src/lib/utils.ts:39
#: src/lib/utils.ts:48
msgid "Failed to authenticate"
msgstr "Failed to authenticate"
#: src/components/routes/settings/layout.tsx:39
#: src/components/routes/settings/notifications.tsx:63
#: src/components/routes/settings/layout.tsx:39
msgid "Failed to save settings"
msgstr "Failed to save settings"
@@ -357,8 +364,8 @@ msgstr "Failed to send test notification"
msgid "Failed to update alert"
msgstr "Failed to update alert"
#: src/components/routes/system.tsx:606
#: src/components/systems-table/systems-table.tsx:326
#: src/components/systems-table/systems-table.tsx:341
#: src/components/routes/system.tsx:641
msgid "Filter..."
msgstr "Filter..."
@@ -366,25 +373,25 @@ msgstr "Filter..."
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
msgstr "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
#: src/components/login/auth-form.tsx:328
#: src/components/login/auth-form.tsx:330
msgid "Forgot password?"
msgstr "Forgot password?"
#. Context: General settings
#: src/components/routes/settings/general.tsx:33
#: src/components/routes/settings/layout.tsx:51
#: src/components/routes/settings/general.tsx:33
msgid "General"
msgstr "General"
#: src/components/routes/system.tsx:510
#: src/components/routes/system.tsx:545
msgid "GPU Power Draw"
msgstr "GPU Power Draw"
#: src/components/systems-table/systems-table.tsx:353
#: src/components/systems-table/systems-table.tsx:368
msgid "Grid"
msgstr "Grid"
#: src/components/add-system.tsx:157
#: src/components/add-system.tsx:158
msgid "Host / IP"
msgstr "Host / IP"
@@ -397,7 +404,7 @@ msgid "Invalid email address."
msgstr "Invalid email address."
#. Linux kernel
#: src/components/routes/system.tsx:264
#: src/components/routes/system.tsx:270
msgid "Kernel"
msgstr "Kernel"
@@ -405,7 +412,7 @@ msgstr "Kernel"
msgid "Language"
msgstr "Language"
#: src/components/systems-table/systems-table.tsx:339
#: src/components/systems-table/systems-table.tsx:354
msgid "Layout"
msgstr "Layout"
@@ -422,13 +429,13 @@ msgstr "Log Out"
msgid "Login"
msgstr "Login"
#: src/components/login/auth-form.tsx:39
#: src/components/login/forgot-pass-form.tsx:15
#: src/components/login/auth-form.tsx:39
msgid "Login attempt failed"
msgstr "Login attempt failed"
#: src/components/command-palette.tsx:157
#: src/components/navbar.tsx:86
#: src/components/command-palette.tsx:155
msgid "Logs"
msgstr "Logs"
@@ -440,60 +447,60 @@ msgstr "Looking instead for where to create alerts? Click the bell <0/> icons in
msgid "Manage display and notification preferences."
msgstr "Manage display and notification preferences."
#: src/components/add-system.tsx:225
#: src/components/add-system.tsx:226
msgid "Manual setup instructions"
msgstr "Manual setup instructions"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx:636
#: src/components/routes/system.tsx:671
msgid "Max 1 min"
msgstr "Max 1 min"
#: src/components/systems-table/systems-table.tsx:173
#: src/components/systems-table/systems-table.tsx:188
msgid "Memory"
msgstr "Memory"
#: src/components/routes/system.tsx:420
#: src/lib/utils.ts:315
#: src/lib/utils.ts:325
#: src/components/routes/system.tsx:455
msgid "Memory Usage"
msgstr "Memory Usage"
#: src/components/routes/system.tsx:431
#: src/components/routes/system.tsx:466
msgid "Memory usage of docker containers"
msgstr "Memory usage of docker containers"
#: src/components/add-system.tsx:153
#: src/components/add-system.tsx:154
msgid "Name"
msgstr "Name"
#: src/components/systems-table/systems-table.tsx:198
#: src/components/systems-table/systems-table.tsx:213
msgid "Net"
msgstr "Net"
#: src/components/routes/system.tsx:472
#: src/components/routes/system.tsx:507
msgid "Network traffic of docker containers"
msgstr "Network traffic of docker containers"
#: src/components/routes/system.tsx:457
#: src/components/routes/system.tsx:492
msgid "Network traffic of public interfaces"
msgstr "Network traffic of public interfaces"
#: src/components/command-palette.tsx:50
#: src/components/command-palette.tsx:48
msgid "No results found."
msgstr "No results found."
#: src/components/systems-table/systems-table.tsx:474
#: src/components/systems-table/systems-table.tsx:547
#: src/components/systems-table/systems-table.tsx:489
#: src/components/systems-table/systems-table.tsx:562
msgid "No systems found."
msgstr "No systems found."
#: src/components/command-palette.tsx:111
#: src/components/routes/settings/layout.tsx:56
#: src/components/command-palette.tsx:109
#: src/components/routes/settings/notifications.tsx:75
#: src/components/routes/settings/layout.tsx:56
msgid "Notifications"
msgstr "Notifications"
#: src/components/login/auth-form.tsx:299
#: src/components/login/auth-form.tsx:301
msgid "OAuth 2 / OIDC support"
msgstr "OAuth 2 / OIDC support"
@@ -501,11 +508,11 @@ msgstr "OAuth 2 / OIDC support"
msgid "On each restart, systems in the database will be updated to match the systems defined in the file."
msgstr "On each restart, systems in the database will be updated to match the systems defined in the file."
#: src/components/systems-table/systems-table.tsx:585
#: src/components/systems-table/systems-table.tsx:600
msgid "Open menu"
msgstr "Open menu"
#: src/components/login/auth-form.tsx:248
#: src/components/login/auth-form.tsx:250
msgid "Or continue with"
msgstr "Or continue with"
@@ -513,16 +520,16 @@ msgstr "Or continue with"
msgid "Overwrite existing alerts"
msgstr "Overwrite existing alerts"
#: src/components/command-palette.tsx:85
#: src/components/command-palette.tsx:83
msgid "Page"
msgstr "Page"
#: src/components/command-palette.tsx:72
#: src/components/command-palette.tsx:70
msgid "Pages / Settings"
msgstr "Pages / Settings"
#: src/components/login/auth-form.tsx:192
#: src/components/login/auth-form.tsx:197
#: src/components/login/auth-form.tsx:194
#: src/components/login/auth-form.tsx:199
msgid "Password"
msgstr "Password"
@@ -538,10 +545,14 @@ msgstr "Password must be less than 72 bytes."
msgid "Password reset request received"
msgstr "Password reset request received"
#: src/components/systems-table/systems-table.tsx:618
#: src/components/systems-table/systems-table.tsx:633
msgid "Pause"
msgstr "Pause"
#: src/components/systems-table/systems-table.tsx:142
msgid "Paused"
msgstr "Paused"
#: src/components/routes/settings/notifications.tsx:96
msgid "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgstr "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
@@ -550,8 +561,8 @@ msgstr "Please <0>configure an SMTP server</0> to ensure alerts are delivered."
msgid "Please check logs for more details."
msgstr "Please check logs for more details."
#: src/components/login/auth-form.tsx:40
#: src/components/login/forgot-pass-form.tsx:16
#: src/components/login/auth-form.tsx:40
msgid "Please check your credentials and try again"
msgstr "Please check your credentials and try again"
@@ -563,11 +574,11 @@ msgstr "Please create an admin account"
msgid "Please enable pop-ups for this site"
msgstr "Please enable pop-ups for this site"
#: src/lib/utils.ts:40
#: src/lib/utils.ts:49
msgid "Please log in again"
msgstr "Please log in again"
#: src/components/login/auth-form.tsx:307
#: src/components/login/auth-form.tsx:309
msgid "Please see <0>the documentation</0> for instructions."
msgstr "Please see <0>the documentation</0> for instructions."
@@ -575,12 +586,12 @@ msgstr "Please see <0>the documentation</0> for instructions."
msgid "Please sign in to your account"
msgstr "Please sign in to your account"
#: src/components/add-system.tsx:169
#: src/components/add-system.tsx:170
msgid "Port"
msgstr "Port"
#: src/components/routes/system.tsx:421
#: src/components/routes/system.tsx:537
#: src/components/routes/system.tsx:456
#: src/components/routes/system.tsx:572
msgid "Precise utilization at the recorded time"
msgstr "Precise utilization at the recorded time"
@@ -589,7 +600,7 @@ msgid "Preferred Language"
msgstr "Preferred Language"
#. Use 'Key' if your language requires many more characters
#: src/components/add-system.tsx:180
#: src/components/add-system.tsx:181
msgid "Public Key"
msgstr "Public Key"
@@ -608,7 +619,7 @@ msgstr "Received"
msgid "Reset Password"
msgstr "Reset Password"
#: src/components/systems-table/systems-table.tsx:613
#: src/components/systems-table/systems-table.tsx:628
msgid "Resume"
msgstr "Resume"
@@ -616,12 +627,12 @@ msgstr "Resume"
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Save address using enter key or comma. Leave blank to disable email notifications."
#: src/components/routes/settings/general.tsx:106
#: src/components/routes/settings/notifications.tsx:168
#: src/components/routes/settings/general.tsx:106
msgid "Save Settings"
msgstr "Save Settings"
#: src/components/add-system.tsx:230
#: src/components/add-system.tsx:231
msgid "Save system"
msgstr "Save system"
@@ -629,7 +640,7 @@ msgstr "Save system"
msgid "Search"
msgstr "Search"
#: src/components/command-palette.tsx:47
#: src/components/command-palette.tsx:45
msgid "Search for systems or settings..."
msgstr "Search for systems or settings..."
@@ -646,9 +657,9 @@ msgstr "Sent"
msgid "Sets the default time range for charts when a system is viewed."
msgstr "Sets the default time range for charts when a system is viewed."
#: src/components/command-palette.tsx:96
#: src/components/command-palette.tsx:99
#: src/components/command-palette.tsx:114
#: src/components/command-palette.tsx:94
#: src/components/command-palette.tsx:97
#: src/components/command-palette.tsx:112
#: src/components/routes/settings/layout.tsx:71
#: src/components/routes/settings/layout.tsx:82
msgid "Settings"
@@ -658,37 +669,37 @@ msgstr "Settings"
msgid "Settings saved"
msgstr "Settings saved"
#: src/components/login/auth-form.tsx:236
#: src/components/login/auth-form.tsx:238
msgid "Sign in"
msgstr "Sign in"
#: src/components/command-palette.tsx:186
#: src/components/command-palette.tsx:184
msgid "SMTP settings"
msgstr "SMTP settings"
#: src/components/systems-table/systems-table.tsx:361
#: src/components/systems-table/systems-table.tsx:376
msgid "Sort By"
msgstr "Sort By"
#: src/lib/utils.ts:301
#: src/lib/utils.ts:311
msgid "Status"
msgstr "Status"
#: src/components/routes/system.tsx:487
#: src/components/routes/system.tsx:522
msgid "Swap space used by the system"
msgstr "Swap space used by the system"
#: src/components/routes/system.tsx:486
#: src/components/routes/system.tsx:521
msgid "Swap Usage"
msgstr "Swap Usage"
#. System theme
#: src/lib/utils.ts:316
#: src/components/mode-toggle.tsx:26
#: src/components/systems-table/systems-table.tsx:125
#: src/components/systems-table/systems-table.tsx:133
#: src/components/systems-table/systems-table.tsx:144
#: src/components/systems-table/systems-table.tsx:518
#: src/lib/utils.ts:306
#: src/components/systems-table/systems-table.tsx:150
#: src/components/systems-table/systems-table.tsx:533
msgid "System"
msgstr "System"
@@ -700,21 +711,21 @@ msgstr "Systems"
msgid "Systems may be managed in a <0>config.yml</0> file inside your data directory."
msgstr "Systems may be managed in a <0>config.yml</0> file inside your data directory."
#: src/components/systems-table/systems-table.tsx:349
#: src/components/systems-table/systems-table.tsx:364
msgid "Table"
msgstr "Table"
#. Temperature label in systems table
#: src/components/systems-table/systems-table.tsx:218
#: src/components/systems-table/systems-table.tsx:233
msgid "Temp"
msgstr "Temp"
#: src/components/routes/system.tsx:498
#: src/lib/utils.ts:334
#: src/lib/utils.ts:344
#: src/components/routes/system.tsx:533
msgid "Temperature"
msgstr "Temperature"
#: src/components/routes/system.tsx:499
#: src/components/routes/system.tsx:534
msgid "Temperatures of system sensors"
msgstr "Temperatures of system sensors"
@@ -726,11 +737,11 @@ msgstr "Test <0>URL</0>"
msgid "Test notification sent"
msgstr "Test notification sent"
#: src/components/add-system.tsx:145
#: src/components/add-system.tsx:146
msgid "The agent must be running on the system to connect. Copy the installation command for the agent below."
msgstr "The agent must be running on the system to connect. Copy the installation command for the agent below."
#: src/components/add-system.tsx:136
#: src/components/add-system.tsx:137
msgid "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
msgstr "The agent must be running on the system to connect. Copy the<0>docker-compose.yml</0> for the agent below."
@@ -738,15 +749,15 @@ msgstr "The agent must be running on the system to connect. Copy the<0>docker-co
msgid "Then log into the backend and reset your user account password in the users table."
msgstr "Then log into the backend and reset your user account password in the users table."
#: src/components/systems-table/systems-table.tsx:645
#: src/components/systems-table/systems-table.tsx:660
msgid "This action cannot be undone. This will permanently delete all current records for {name} from the database."
msgstr "This action cannot be undone. This will permanently delete all current records for {name} from the database."
#: src/components/routes/system.tsx:578
#: src/components/routes/system.tsx:613
msgid "Throughput of {extraFsName}"
msgstr "Throughput of {extraFsName}"
#: src/components/routes/system.tsx:446
#: src/components/routes/system.tsx:481
msgid "Throughput of root filesystem"
msgstr "Throughput of root filesystem"
@@ -754,8 +765,8 @@ msgstr "Throughput of root filesystem"
msgid "To email(s)"
msgstr "To email(s)"
#: src/components/routes/system.tsx:373
#: src/components/routes/system.tsx:386
#: src/components/routes/system.tsx:408
#: src/components/routes/system.tsx:421
msgid "Toggle grid"
msgstr "Toggle grid"
@@ -763,73 +774,74 @@ msgstr "Toggle grid"
msgid "Toggle theme"
msgstr "Toggle theme"
#: src/lib/utils.ts:337
#: src/lib/utils.ts:347
msgid "Triggers when any sensor exceeds a threshold"
msgstr "Triggers when any sensor exceeds a threshold"
#: src/lib/utils.ts:330
#: src/lib/utils.ts:340
msgid "Triggers when combined up/down exceeds a threshold"
msgstr "Triggers when combined up/down exceeds a threshold"
#: src/lib/utils.ts:312
#: src/lib/utils.ts:322
msgid "Triggers when CPU usage exceeds a threshold"
msgstr "Triggers when CPU usage exceeds a threshold"
#: src/lib/utils.ts:318
#: src/lib/utils.ts:328
msgid "Triggers when memory usage exceeds a threshold"
msgstr "Triggers when memory usage exceeds a threshold"
#: src/lib/utils.ts:304
#: src/lib/utils.ts:314
msgid "Triggers when status switches between up and down"
msgstr "Triggers when status switches between up and down"
#: src/lib/utils.ts:324
#: src/lib/utils.ts:334
msgid "Triggers when usage of any disk exceeds a threshold"
msgstr "Triggers when usage of any disk exceeds a threshold"
#. Context: System is up
#: src/components/routes/system.tsx:307
#: src/components/systems-table/systems-table.tsx:140
#: src/components/routes/system.tsx:342
msgid "Up"
msgstr "Up"
#: src/components/systems-table/systems-table.tsx:322
#: src/components/systems-table/systems-table.tsx:337
msgid "Updated in real time. Click on a system to view information."
msgstr "Updated in real time. Click on a system to view information."
#: src/components/routes/system.tsx:263
#: src/components/routes/system.tsx:269
msgid "Uptime"
msgstr "Uptime"
#: src/components/routes/system.tsx:563
#: src/components/routes/system.tsx:600
#: src/components/charts/area-chart.tsx:73
#: src/components/routes/system.tsx:528
#: src/components/routes/system.tsx:565
msgid "Usage"
msgstr "Usage"
#: src/components/routes/system.tsx:438
#: src/components/routes/system.tsx:473
msgid "Usage of root partition"
msgstr "Usage of root partition"
#: src/components/charts/area-chart.tsx:73
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/swap-chart.tsx:56
#: src/components/charts/mem-chart.tsx:65
#: src/components/charts/area-chart.tsx:73
msgid "Used"
msgstr "Used"
#: src/components/command-palette.tsx:143
#: src/components/navbar.tsx:70
#: src/components/command-palette.tsx:141
msgid "Users"
msgstr "Users"
#: src/components/systems-table/systems-table.tsx:331
#: src/components/systems-table/systems-table.tsx:346
msgid "View"
msgstr "View"
#: src/components/systems-table/systems-table.tsx:395
#: src/components/systems-table/systems-table.tsx:410
msgid "Visible Fields"
msgstr "Visible Fields"
#: src/components/routes/system.tsx:670
#: src/components/routes/system.tsx:705
msgid "Waiting for enough records to display"
msgstr "Waiting for enough records to display"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
package beszel
const (
Version = "0.9.1"
Version = "0.10.1"
AppName = "beszel"
)

View File

@@ -21,7 +21,6 @@ ProtectClock=yes
ProtectHome=read-only
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelTunables=yes
ProtectSystem=strict
RemoveIPC=yes
RestrictSUIDSGID=true

View File

@@ -483,7 +483,6 @@ ProtectClock=yes
ProtectHome=read-only
ProtectHostname=yes
ProtectKernelLogs=yes
ProtectKernelTunables=yes
ProtectSystem=strict
RemoveIPC=yes
RestrictSUIDSGID=true