Compare commits

...

57 Commits

Author SHA1 Message Date
henrygd
7057f2e917 release 0.10.2 2025-03-15 03:01:57 -04:00
henrygd
47b2689f24 updates for lingui 5 2025-03-15 03:01:49 -04:00
henrygd
9b65110aef fix dumb error i pushed five min ago :) 2025-03-15 01:16:18 -04:00
henrygd
3935a9bf00 i18n: update language files 2025-03-15 01:03:20 -04:00
henrygd
fb2adf08dc remove "GPU" from translations 2025-03-15 01:03:11 -04:00
stanol
61441b115b i18n: new Ukrainian translations 2025-03-15 00:58:38 -04:00
aroxu
3ad78a2588 i18n: new Korean translations 2025-03-15 00:57:51 -04:00
harupong
81514d4deb i18n: new Japanese translations 2025-03-15 00:55:14 -04:00
NickAss512
faeb801512 i18n: new cs translations 2025-03-15 00:54:08 -04:00
henrygd
968ca70670 agent temperature fixes (#648, #663)
- Fixes a bad sensor returning an error instead of other good sensors
- Adds ability to set GPU as PRIMARY_SENSOR
2025-03-15 00:29:41 -04:00
henrygd
5837b4f25c Refactor hub initialization and error handling 2025-03-15 00:25:42 -04:00
henrygd
c38d04b34b Add health command for hub and align agent health command 2025-03-15 00:23:12 -04:00
henrygd
cadc09b493 delete duplicate alerts as part of 0.10.2 migrations 2025-03-14 23:03:07 -04:00
henrygd
edefc6f53e add health check for agent
- Updated command-line flag parsing.
- Moved GetAddress and GetNetwork to server.go
2025-03-14 03:33:25 -04:00
henrygd
400ea89587 correct version in upcoming migration name 2025-03-13 22:05:29 -04:00
henrygd
3058c24e82 Allow dynamic size value in default area chart
- Fixes system pause / unpause rendering
- Some formatting
2025-03-13 22:04:29 -04:00
henrygd
521be05bc1 gpu.go refactoring and jetson fixes
- Fixed usage and power values
- Added new test cases
- Moved some variables to constants
2025-03-13 21:32:53 -04:00
henrygd
6b766b2653 add unique multicolumn index to prevent duplicate alerts (#662) 2025-03-13 02:20:27 -04:00
henrygd
d36b8369cc react refactoring for better performance with lots of systems 2025-03-13 02:16:54 -04:00
henrygd
ae22334645 pass hub version through html and update dependencies
- Changed Vite configuration to replace version during development.
2025-03-13 02:15:03 -04:00
henrygd
1d7c0ebc27 fix: error adding alerts to 50+ systems (#664)
- Fixed PB SDK autocancelling requests.
- Refactored SystemAlert and SystemAlertGlobal components.
- Introduced a batchWrapper function for handling batch operations on alerts.
2025-03-13 02:07:58 -04:00
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
98 changed files with 11887 additions and 10724 deletions

3
.gitignore vendored
View File

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

View File

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

View File

@@ -7,50 +7,63 @@ import (
"fmt" "fmt"
"log" "log"
"os" "os"
"strings"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
// cli options // cli options
type cmdOptions struct { type cmdOptions struct {
key string // key is the public key(s) for SSH authentication. key string // key is the public key(s) for SSH authentication.
addr string // addr is the address or port to listen on. listen string // listen is the address or port to listen on.
} }
// parseFlags parses the command line flags and populates the config struct. // parse parses the command line flags and populates the config struct.
func (opts *cmdOptions) parseFlags() { // It returns true if a subcommand was handled and the program should exit.
func (opts *cmdOptions) parse() bool {
flag.StringVar(&opts.key, "key", "", "Public key(s) for SSH authentication") 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() { flag.Usage = func() {
fmt.Printf("Usage: %s [options] [subcommand]\n", os.Args[0]) fmt.Printf("Usage: %s [command] [flags]\n", os.Args[0])
fmt.Println("\nOptions:") fmt.Println("\nCommands:")
fmt.Println(" health Check if the agent is running")
fmt.Println(" help Display this help message")
fmt.Println(" update Update to the latest version")
fmt.Println(" version Display the version")
fmt.Println("\nFlags:")
flag.PrintDefaults() flag.PrintDefaults()
fmt.Println("\nSubcommands:")
fmt.Println(" version Display the version")
fmt.Println(" help Display this help message")
fmt.Println(" update Update the agent to the latest version")
} }
}
// handleSubcommand handles subcommands such as version, help, and update. subcommand := ""
// It returns true if a subcommand was handled, false otherwise. if len(os.Args) > 1 {
func handleSubcommand() bool { subcommand = os.Args[1]
if len(os.Args) <= 1 {
return false
} }
switch os.Args[1] {
case "version", "-v": switch subcommand {
case "-v", "version":
fmt.Println(beszel.AppName+"-agent", beszel.Version) fmt.Println(beszel.AppName+"-agent", beszel.Version)
os.Exit(0) return true
case "help": case "help":
flag.Usage() flag.Usage()
os.Exit(0) return true
case "update": case "update":
agent.Update() agent.Update()
os.Exit(0) return true
case "health":
// for health, we need to parse flags first to get the listen address
args := append(os.Args[2:], subcommand)
flag.CommandLine.Parse(args)
addr := opts.getAddress()
network := agent.GetNetwork(addr)
err := agent.Health(addr, network)
if err != nil {
log.Fatal(err)
}
fmt.Print("ok")
return true
} }
flag.Parse()
return false return false
} }
@@ -79,46 +92,18 @@ func (opts *cmdOptions) loadPublicKeys() ([]ssh.PublicKey, error) {
return agent.ParseKeys(string(pubKey)) return agent.ParseKeys(string(pubKey))
} }
// getAddress gets the address to listen on from the command line flag, environment variable, or default value.
func (opts *cmdOptions) getAddress() string { func (opts *cmdOptions) getAddress() string {
// Try command line flag first return agent.GetAddress(opts.listen)
if opts.addr != "" {
return opts.addr
}
// Try environment variables
if addr, ok := agent.GetEnv("ADDR"); ok && addr != "" {
return addr
}
// Legacy PORT environment variable support
if port, ok := agent.GetEnv("PORT"); ok && port != "" {
return port
}
return ":45876"
}
// getNetwork returns the network type to use for the server.
func (opts *cmdOptions) getNetwork() string {
if network, _ := agent.GetEnv("NETWORK"); network != "" {
return network
}
if strings.HasPrefix(opts.addr, "/") {
return "unix"
}
return "tcp"
} }
func main() { func main() {
var opts cmdOptions var opts cmdOptions
opts.parseFlags() subcommandHandled := opts.parse()
if handleSubcommand() { if subcommandHandled {
return return
} }
flag.Parse()
opts.addr = opts.getAddress()
var serverConfig agent.ServerOptions var serverConfig agent.ServerOptions
var err error var err error
serverConfig.Keys, err = opts.loadPublicKeys() serverConfig.Keys, err = opts.loadPublicKeys()
@@ -126,8 +111,9 @@ func main() {
log.Fatal("Failed to load public keys:", err) log.Fatal("Failed to load public keys:", err)
} }
serverConfig.Addr = opts.addr addr := opts.getAddress()
serverConfig.Network = opts.getNetwork() serverConfig.Addr = addr
serverConfig.Network = agent.GetNetwork(addr)
agent := agent.NewAgent() agent := agent.NewAgent()
if err := agent.StartServer(serverConfig); err != nil { if err := agent.StartServer(serverConfig); err != nil {

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"beszel/internal/agent"
"crypto/ed25519" "crypto/ed25519"
"flag" "flag"
"os" "os"
@@ -27,22 +28,22 @@ func TestGetAddress(t *testing.T) {
{ {
name: "use address from flag", name: "use address from flag",
opts: cmdOptions{ opts: cmdOptions{
addr: "8080", listen: "8080",
}, },
expected: "8080", expected: ":8080",
}, },
{ {
name: "use unix socket from flag", name: "use unix socket from flag",
opts: cmdOptions{ opts: cmdOptions{
addr: "/tmp/beszel.sock", listen: "/tmp/beszel.sock",
}, },
expected: "/tmp/beszel.sock", expected: "/tmp/beszel.sock",
}, },
{ {
name: "use ADDR env var", name: "use LISTEN env var",
opts: cmdOptions{}, opts: cmdOptions{},
envVars: map[string]string{ envVars: map[string]string{
"ADDR": "1.2.3.4:9090", "LISTEN": "1.2.3.4:9090",
}, },
expected: "1.2.3.4:9090", expected: "1.2.3.4:9090",
}, },
@@ -52,26 +53,26 @@ func TestGetAddress(t *testing.T) {
envVars: map[string]string{ envVars: map[string]string{
"PORT": "7070", "PORT": "7070",
}, },
expected: "7070", expected: ":7070",
}, },
{ {
name: "use unix socket from env var", name: "use unix socket from env var",
opts: cmdOptions{ opts: cmdOptions{
addr: "", listen: "",
}, },
envVars: map[string]string{ envVars: map[string]string{
"ADDR": "/tmp/beszel.sock", "LISTEN": "/tmp/beszel.sock",
}, },
expected: "/tmp/beszel.sock", expected: "/tmp/beszel.sock",
}, },
{ {
name: "flag takes precedence over env vars", name: "flag takes precedence over env vars",
opts: cmdOptions{ opts: cmdOptions{
addr: ":8080", listen: ":8080",
}, },
envVars: map[string]string{ envVars: map[string]string{
"ADDR": ":9090", "LISTEN": ":9090",
"PORT": "7070", "PORT": "7070",
}, },
expected: ":8080", expected: ":8080",
}, },
@@ -201,27 +202,27 @@ func TestGetNetwork(t *testing.T) {
}, },
{ {
name: "only port", name: "only port",
opts: cmdOptions{addr: "8080"}, opts: cmdOptions{listen: "8080"},
expected: "tcp", expected: "tcp",
}, },
{ {
name: "ipv4 address", name: "ipv4 address",
opts: cmdOptions{addr: "1.2.3.4:8080"}, opts: cmdOptions{listen: "1.2.3.4:8080"},
expected: "tcp", expected: "tcp",
}, },
{ {
name: "ipv6 address", name: "ipv6 address",
opts: cmdOptions{addr: "[2001:db8::1]:8080"}, opts: cmdOptions{listen: "[2001:db8::1]:8080"},
expected: "tcp", expected: "tcp",
}, },
{ {
name: "unix network", name: "unix network",
opts: cmdOptions{addr: "/tmp/beszel.sock"}, opts: cmdOptions{listen: "/tmp/beszel.sock"},
expected: "unix", expected: "unix",
}, },
{ {
name: "env var network", name: "env var network",
opts: cmdOptions{addr: ":8080"}, opts: cmdOptions{listen: ":8080"},
envVars: map[string]string{"NETWORK": "tcp4"}, envVars: map[string]string{"NETWORK": "tcp4"},
expected: "tcp4", expected: "tcp4",
}, },
@@ -233,7 +234,7 @@ func TestGetNetwork(t *testing.T) {
for k, v := range tt.envVars { for k, v := range tt.envVars {
t.Setenv(k, v) t.Setenv(k, v)
} }
network := tt.opts.getNetwork() network := agent.GetNetwork(tt.opts.listen)
assert.Equal(t, tt.expected, network) assert.Equal(t, tt.expected, network)
}) })
} }
@@ -256,32 +257,32 @@ func TestParseFlags(t *testing.T) {
name: "no flags", name: "no flags",
args: []string{"cmd"}, args: []string{"cmd"},
expected: cmdOptions{ expected: cmdOptions{
key: "", key: "",
addr: "", listen: "",
}, },
}, },
{ {
name: "key flag only", name: "key flag only",
args: []string{"cmd", "-key", "testkey"}, args: []string{"cmd", "-key", "testkey"},
expected: cmdOptions{ expected: cmdOptions{
key: "testkey", key: "testkey",
addr: "", listen: "",
}, },
}, },
{ {
name: "addr flag only", name: "addr flag only",
args: []string{"cmd", "-addr", ":8080"}, args: []string{"cmd", "-listen", ":8080"},
expected: cmdOptions{ expected: cmdOptions{
key: "", key: "",
addr: ":8080", listen: ":8080",
}, },
}, },
{ {
name: "both flags", name: "both flags",
args: []string{"cmd", "-key", "testkey", "-addr", ":8080"}, args: []string{"cmd", "-key", "testkey", "-listen", ":8080"},
expected: cmdOptions{ expected: cmdOptions{
key: "testkey", key: "testkey",
addr: ":8080", listen: ":8080",
}, },
}, },
} }
@@ -293,7 +294,7 @@ func TestParseFlags(t *testing.T) {
os.Args = tt.args os.Args = tt.args
var opts cmdOptions var opts cmdOptions
opts.parseFlags() opts.parse()
flag.Parse() flag.Parse()
assert.Equal(t, tt.expected, opts) assert.Equal(t, tt.expected, opts)

View File

@@ -1,10 +1,99 @@
package main package main
import ( import (
"beszel"
"beszel/internal/hub" "beszel/internal/hub"
_ "beszel/migrations" _ "beszel/migrations"
"fmt"
"log"
"net/http"
"os"
"time"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/spf13/cobra"
) )
func main() { func main() {
hub.NewHub().Run() // handle health check first to prevent unneeded execution
if len(os.Args) > 3 && os.Args[1] == "health" {
url := os.Args[3]
if err := checkHealth(url); err != nil {
log.Fatal(err)
}
fmt.Print("ok")
return
}
baseApp := getBaseApp()
h := hub.NewHub(baseApp)
if err := h.StartHub(); err != nil {
log.Fatal(err)
}
}
// 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,
})
// add health command
baseApp.RootCmd.AddCommand(newHealthCmd())
// 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
}
func newHealthCmd() *cobra.Command {
var baseURL string
healthCmd := &cobra.Command{
Use: "health",
Short: "Check health of running hub",
Run: func(cmd *cobra.Command, args []string) {
if err := checkHealth(baseURL); err != nil {
log.Fatal(err)
}
os.Exit(0)
},
}
healthCmd.Flags().StringVar(&baseURL, "url", "", "base URL")
healthCmd.MarkFlagRequired("url")
return healthCmd
}
// checkHealth checks the health of the hub.
func checkHealth(baseURL string) error {
client := &http.Client{
Timeout: time.Second * 3,
}
healthURL := baseURL + "/api/health"
resp, err := client.Get(healthURL)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("%s returned status %d", healthURL, resp.StatusCode)
}
return nil
} }

View File

@@ -8,39 +8,39 @@ require (
github.com/gliderlabs/ssh v0.3.8 github.com/gliderlabs/ssh v0.3.8
github.com/goccy/go-json v0.10.5 github.com/goccy/go-json v0.10.5
github.com/pocketbase/dbx v1.11.0 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/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/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 github.com/stretchr/testify v1.10.0
golang.org/x/crypto v0.32.0 golang.org/x/crypto v0.35.0
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c golang.org/x/exp v0.0.0-20250228200357-dead58393ab7
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
require ( require (
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // 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 v1.36.3 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 // 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.6 // indirect github.com/aws/aws-sdk-go-v2/config v1.29.8 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 // 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.28 // 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.59 // 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.32 // 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.32 // 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.2 // 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.32 // 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.2 // 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.5.6 // 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.13 // 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.13 // 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.75.4 // indirect github.com/aws/aws-sdk-go-v2/service/s3 v1.78.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 // indirect github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 // 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.14 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.16 // indirect
github.com/aws/smithy-go v1.22.2 // indirect github.com/aws/smithy-go v1.22.3 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect github.com/disintegration/imaging v1.6.2 // indirect
github.com/domodwyer/mailyak/v3 v3.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/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
@@ -75,19 +75,18 @@ require (
go.opencensus.io v0.24.0 // indirect go.opencensus.io v0.24.0 // indirect
gocloud.dev v0.40.0 // indirect gocloud.dev v0.40.0 // indirect
golang.org/x/image v0.24.0 // indirect golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.34.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/oauth2 v0.26.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.11.0 // indirect golang.org/x/sync v0.11.0 // indirect
golang.org/x/sys v0.30.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/text v0.22.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.220.0 // indirect google.golang.org/api v0.223.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250227231956-55c901821b1e // indirect
google.golang.org/grpc v1.70.0 // indirect google.golang.org/grpc v1.70.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect google.golang.org/protobuf v1.36.5 // indirect
modernc.org/libc v1.55.3 // indirect modernc.org/libc v1.61.13 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.8.2 // 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.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 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU= 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.15.0 h1:Ly0u4aA5vG/fsSsxu98qCQBemXtAtJf+95z9HK+cxps=
cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM= 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 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= 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= 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/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 h1:KKUZBfBoyqy5d3swXyiC7Q76ic40rYcbqH7qjh59kzU=
github.com/aws/aws-sdk-go v1.55.5/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU= 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.3 h1:mJoei2CxPutQVxaATCzDUjcZEjVRdpsiiXi2o38yqWM=
github.com/aws/aws-sdk-go-v2 v1.36.1/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM= 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.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0= 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.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg= 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.6 h1:fqgqEKK5HaZVWLQoLiC9Q+xDlSp+1LYidp6ybGE2OGg= github.com/aws/aws-sdk-go-v2/config v1.29.8 h1:RpwAfYcV2lr/yRc4lWhUM9JRPQqKgKWmou3LV7UfWP4=
github.com/aws/aws-sdk-go-v2/config v1.29.6/go.mod h1:Ft+WLODzDQmCTHDvqAH1JfC2xxbZ0MxpZAcJqmE1LTQ= 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.59 h1:9btwmrt//Q6JcSdgJOLI98sdr5p7tssS9yAsGe8aKP4= 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.59/go.mod h1:NM8fM6ovI3zak23UISdWidyZuI1ghNe2xjzUZAyT+08= 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.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso= 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.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI= 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.59 h1:5Vsrfdlf9KQP3leGX1dD7VwZq/3HAerEFoXAII4t6zo= 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.59/go.mod h1:7XTNs3NYApJjkx6A2Fk9qq23qBuBnIU58k3fKC2Fr1I= 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.32 h1:BjUcr3X3K0wZPGFg2bxOWW3VPN8rkE3/61zhP+IHviA= 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.32/go.mod h1:80+OGC/bgzzFFTUmcuwD0lb4YutwQeKLFpmt6hoWapU= 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.32 h1:m1GeXHVMJsRsUAqG6HjZWx9dj7F5TR+cF1bjyfYyBd4= 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.32/go.mod h1:IitoQxGfaKdVLNg0hD8/DXmAqNy0H4K2H2Sf91ti8sI= 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.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk= 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.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc= 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.32 h1:OIHj/nAhVzIXGzbAE+4XmZ8FPvro3THr6NlqErJc3wY= 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.32/go.mod h1:LiBEsDo34OJXqdDlRGsilhlIiXR7DL+6Cx2f4p1EgzI= 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.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA= 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.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY= 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.5.6 h1:cCBJaT7EeEojpJ4s7wTDbhZlHVJOgNHN7iw6qVurGaw= 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.5.6/go.mod h1:WYH1ABybY7JK9TITPnk6ZlP7gQB8psI4c9qDmMsnLSA= 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.13 h1:SYVGSFQHlchIcy6e7x12bsrxClCXSP5et8cqVhL8cuw= 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.13/go.mod h1:kizuDaLX37bG5WZaoxGPQR/LNFXpxp0vsUnqfkWXfNE= 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.13 h1:OBsrtam3rk8NfBEq7OLOMm5HtQ9Yyw32X4UQMya/wjw= 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.13/go.mod h1:3U4gFA5pmoCOja7aq4nSaIAGbaOHv2Yl2ug018cmC+Q= 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.75.4 h1:DJYjOvNgC30JAcDCRmtQHoYK4trc7XetDXRTEAReGKA= 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.75.4/go.mod h1:KuLNrwYJFaC2AVZ+CVVc12k9NyqwgWsoNNHjwqF6QNk= 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.24.15 h1:/eE3DogBjYlvlbhd2ssWyeuovWunHLxfgw3s/OJa4GQ= github.com/aws/aws-sdk-go-v2/service/sso v1.25.0 h1:2U9sF8nKy7UgyEeLiZTRg6ShBS22z8UnYpV6aRFL0is=
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15/go.mod h1:2PCJYpi7EKeA5SkStAmZlF6fi0uUABuhtF8ILHjGc3Y= 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.28.14 h1:M/zwXiL2iXUrHputuXgmO94TVNmcenPHxgLXLutodKE= github.com/aws/aws-sdk-go-v2/service/ssooidc v1.29.0 h1:wjAdc85cXdQR5uLx5FwWvGIHm4OPJhTyzUHU8craXtE=
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/ssooidc v1.29.0/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs=
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.16 h1:BHEK2Q/7CMRMCb3nySi/w8UbIcPhKvYP5s1xf8/izn0=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14/go.mod h1:dspXf/oYWGWo6DEvj98wpaTeqt5+DMidZD0A9BYTizc= github.com/aws/aws-sdk-go-v2/service/sts v1.33.16/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4=
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ= github.com/aws/smithy-go v1.22.3 h1:Z//5NuZCSW6R4PhQ93hShNbyBbn8BWCmCVCt+Q8Io5k=
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= 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 h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ=
github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= 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= 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/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 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= 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.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.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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.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.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.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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8= 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= 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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-20250224150550-a661cff19cfb h1:YU0XAr3+rMpM8fP80KEesn32Qa9qkbquokvuwzWyYuA=
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= 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 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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= 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/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 h1:LpZezioMfT3K4tLrqA55wWFw1EtH1pM4tzSVa7kgszU=
github.com/pocketbase/dbx v1.11.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs= 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.9 h1:/PSJcy39vEGv4lsBG4HV0ZFLcFsTdK9oMkJbxVlVJSs=
github.com/pocketbase/pocketbase v0.25.0/go.mod h1:tOtOv7f3vJhAiyUluIwV9JPuKeknZRQ9F6uJE3W/ntI= 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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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= 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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 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/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.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI= 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 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 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.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 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.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 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 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.59.0 h1:rgMkmiGfix9vFJDcDi1PK8WEQP4FLQwLDfhp5ZLpFeE=
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0= 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.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= 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 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 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= 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-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-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.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= golang.org/x/crypto v0.35.0 h1:b15kiHdrGCHrP6LvwaQ3c03kgNhhiMgvlhxHQhmg2Xs=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 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-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-20250228200357-dead58393ab7 h1:aWwlzYV971S4BXRS9AmqwDLAD85ouC6X+pocatKY58c=
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU= 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.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 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 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-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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 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.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 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-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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/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-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-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.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 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-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.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.26.0 h1:afQXWNNaeC4nvZ0Ed9XvCCzXM6UHJG7iCg0W4fPqSBE= golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 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-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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.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 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= 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.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 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-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-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-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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 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.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= 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-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 h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY=
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 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.223.0 h1:JUTaWEriXmEy5AhvdMgksGGPEFsYfUKaPEYXd4c3Wvc=
google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY= 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.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.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.4.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 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 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/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-20250227231956-55c901821b1e h1:YA5lmSs3zc/5w+xsRcHqpETkaYyK63ivEPzNTcUUlSA=
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/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 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.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 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.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.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.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.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 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 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 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 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= 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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/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.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= modernc.org/cc/v4 v4.24.4/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= modernc.org/ccgo/v4 v4.23.16 h1:Z2N+kk38b7SfySC1ZkpGLN2vthNJP1+ZzGZIlH7uBxo=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= 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 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 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.6.3 h1:aJVhcqAte49LF+mGveZ5KPlsp4tdGdAOT4sipJXADjw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= modernc.org/gc/v2 v2.6.3/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= modernc.org/libc v1.61.13 h1:3LRd6ZO1ezsFiX1y+bHd1ipyEHIJKvuprv0sLTBwLW8=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= 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 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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 h1:cL9L4bcoAObu4NkxOlKWBWtNHIsnnACGF/TbqQ6sbcI=
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU= 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.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g= modernc.org/sqlite v1.35.0 h1:yQps4fegMnZFdphtzlfQTCNBWtS0CZv48pRpW3RFHRw=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE= modernc.org/sqlite v1.35.0/go.mod h1:9cr2sicr7jIaWTBKQmAxQLfBv9LL0su4ZTEV+utt3ic=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -9,6 +9,7 @@ import (
"os" "os"
"strings" "strings"
"sync" "sync"
"time"
"github.com/shirou/gopsutil/v4/common" "github.com/shirou/gopsutil/v4/common"
) )
@@ -25,16 +26,20 @@ type Agent struct {
dockerManager *dockerManager // Manages Docker API requests dockerManager *dockerManager // Manages Docker API requests
sensorsContext context.Context // Sensors context to override sys location sensorsContext context.Context // Sensors context to override sys location
sensorsWhitelist map[string]struct{} // List of sensors to monitor sensorsWhitelist map[string]struct{} // List of sensors to monitor
primarySensor string // Value of PRIMARY_SENSOR env var
systemInfo system.Info // Host system info systemInfo system.Info // Host system info
gpuManager *GPUManager // Manages GPU data gpuManager *GPUManager // Manages GPU data
cache *SessionCache // Cache for system stats based on primary session ID
} }
func NewAgent() *Agent { func NewAgent() *Agent {
agent := &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") agent.memCalc, _ = GetEnv("MEM_CALC")
agent.primarySensor, _ = GetEnv("PRIMARY_SENSOR")
// Set up slog with a log level determined by the LOG_LEVEL env var // Set up slog with a log level determined by the LOG_LEVEL env var
if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists { if logLevelStr, exists := GetEnv("LOG_LEVEL"); exists {
switch strings.ToLower(logLevelStr) { switch strings.ToLower(logLevelStr) {
@@ -56,14 +61,12 @@ func NewAgent() *Agent {
agent.sensorsContext = context.WithValue(agent.sensorsContext, agent.sensorsContext = context.WithValue(agent.sensorsContext,
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors}, common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
) )
} else {
agent.sensorsContext = context.Background()
} }
// Set sensors whitelist // Set sensors whitelist
if sensors, exists := GetEnv("SENSORS"); exists { if sensors, exists := GetEnv("SENSORS"); exists {
agent.sensorsWhitelist = make(map[string]struct{}) agent.sensorsWhitelist = make(map[string]struct{})
for _, sensor := range strings.Split(sensors, ",") { for sensor := range strings.SplitSeq(sensors, ",") {
if sensor != "" { if sensor != "" {
agent.sensorsWhitelist[sensor] = struct{}{} agent.sensorsWhitelist[sensor] = struct{}{}
} }
@@ -85,7 +88,7 @@ func NewAgent() *Agent {
// if debugging, print stats // if debugging, print stats
if agent.debug { if agent.debug {
slog.Debug("Stats", "data", agent.gatherStats()) slog.Debug("Stats", "data", agent.gatherStats(""))
} }
return agent return agent
@@ -100,29 +103,37 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key) return os.LookupEnv(key)
} }
func (a *Agent) gatherStats() system.CombinedData { func (a *Agent) gatherStats(sessionID string) *system.CombinedData {
a.Lock() a.Lock()
defer a.Unlock() 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(), Stats: a.getSystemStats(),
Info: a.systemInfo, Info: a.systemInfo,
} }
slog.Debug("System stats", "data", systemData) slog.Debug("System stats", "data", cachedData)
// add docker stats
if containerStats, err := a.dockerManager.getDockerStats(); err == nil { if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
systemData.Containers = containerStats cachedData.Containers = containerStats
slog.Debug("Docker stats", "data", systemData.Containers) slog.Debug("Docker stats", "data", cachedData.Containers)
} else { } 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 { for name, stats := range a.fsStats {
if !stats.Root && stats.DiskTotal > 0 { if !stats.Root && stats.DiskTotal > 0 {
systemData.Stats.ExtraFs[name] = stats cachedData.Stats.ExtraFs[name] = stats
} }
} }
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs) slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
return systemData
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 wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap 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 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 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) 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 // Add goroutine to the queue
func (d *dockerManager) queue() { func (d *dockerManager) queue() {
d.wg.Add(1) d.wg.Add(1)
@@ -52,11 +64,12 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
dm.apiContainerList = dm.apiContainerList[:0]
if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil { if err := json.NewDecoder(resp.Body).Decode(&dm.apiContainerList); err != nil {
return nil, err return nil, err
} }
containersLength := len(*dm.apiContainerList) containersLength := len(dm.apiContainerList)
// store valid ids to clean up old container ids from map // store valid ids to clean up old container ids from map
if dm.validIds == nil { if dm.validIds == nil {
@@ -65,9 +78,10 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
clear(dm.validIds) 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] ctr.IdShort = ctr.Id[:12]
dm.validIds[ctr.IdShort] = struct{}{} dm.validIds[ctr.IdShort] = struct{}{}
// check if container is less than 1 minute old (possible restart) // 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 { if err != nil {
dm.containerStatsMutex.Lock() dm.containerStatsMutex.Lock()
delete(dm.containerStatsMap, ctr.IdShort) delete(dm.containerStatsMap, ctr.IdShort)
failedContainters = append(failedContainters, ctr) failedContainers = append(failedContainers, ctr)
dm.containerStatsMutex.Unlock() dm.containerStatsMutex.Unlock()
} }
}() }()
@@ -93,9 +107,9 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
dm.wg.Wait() dm.wg.Wait()
// retry failed containers separately so we can run them in parallel (docker 24 bug) // retry failed containers separately so we can run them in parallel (docker 24 bug)
if len(failedContainters) > 0 { if len(failedContainers) > 0 {
slog.Debug("Retrying failed containers", "count", len(failedContainters)) slog.Debug("Retrying failed containers", "count", len(failedContainers))
for _, ctr := range failedContainters { for _, ctr := range failedContainers {
dm.queue() dm.queue()
go func() { go func() {
defer dm.dequeue() defer dm.dequeue()
@@ -122,7 +136,7 @@ func (dm *dockerManager) getDockerStats() ([]*container.Stats, error) {
} }
// Updates stats for individual container // 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:] name := ctr.Names[0][1:]
resp, err := dm.client.Get("http://localhost/containers/" + ctr.IdShort + "/stats?stream=0&one-shot=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) 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{ client: &http.Client{
Timeout: timeout, Timeout: timeout,
Transport: transport, Transport: userAgentTransport,
}, },
containerStatsMap: make(map[string]*container.Stats), containerStatsMap: make(map[string]*container.Stats),
sem: make(chan struct{}, 5), sem: make(chan struct{}, 5),
apiContainerList: []*container.ApiInfo{},
} }
// If using podman, return client // If using podman, return client
if strings.Contains(dockerHost, "podman") { if strings.Contains(dockerHost, "podman") {
a.systemInfo.Podman = true a.systemInfo.Podman = true
dockerClient.goodDockerVersion = true manager.goodDockerVersion = true
return dockerClient return manager
} }
// Check docker version // Check docker version
@@ -272,23 +293,24 @@ func newDockerManager(a *Agent) *dockerManager {
var versionInfo struct { var versionInfo struct {
Version string `json:"Version"` Version string `json:"Version"`
} }
resp, err := dockerClient.client.Get("http://localhost/version") resp, err := manager.client.Get("http://localhost/version")
if err != nil { if err != nil {
return dockerClient return manager
} }
defer resp.Body.Close()
if err := json.NewDecoder(resp.Body).Decode(&versionInfo); err != nil { 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 version > 24, one-shot works correctly and we can limit concurrent operations
if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 { if dockerVersion, err := semver.Parse(versionInfo.Version); err == nil && dockerVersion.Major > 24 {
dockerClient.goodDockerVersion = true manager.goodDockerVersion = true
} else { } else {
slog.Info(fmt.Sprintf("Docker %s is outdated. Upgrade if possible. See https://github.com/henrygd/beszel/issues/58", versionInfo.Version)) 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 // Test docker / podman sockets and return if one exists

View File

@@ -3,6 +3,7 @@ package agent
import ( import (
"beszel/internal/entities/system" "beszel/internal/entities/system"
"bufio" "bufio"
"bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"os/exec" "os/exec"
@@ -15,6 +16,28 @@ import (
"golang.org/x/exp/slog" "golang.org/x/exp/slog"
) )
const (
// Commands
nvidiaSmiCmd = "nvidia-smi"
rocmSmiCmd = "rocm-smi"
tegraStatsCmd = "tegrastats"
// Polling intervals
nvidiaSmiInterval = "4" // in seconds
tegraStatsInterval = "3700" // in milliseconds
rocmSmiInterval = 4300 * time.Millisecond
// Command retry and timeout constants
retryWaitTime = 5 * time.Second
maxFailureRetries = 5
cmdBufferSize = 10 * 1024
// Unit Conversions
mebibytesInAMegabyte = 1.024 // nvidia-smi reports memory in MiB
milliwattsInAWatt = 1000.0 // tegrastats reports power in mW
)
// GPUManager manages data collection for GPUs (either Nvidia or AMD) // GPUManager manages data collection for GPUs (either Nvidia or AMD)
type GPUManager struct { type GPUManager struct {
sync.Mutex sync.Mutex
@@ -56,7 +79,7 @@ func (c *gpuCollector) start() {
break break
} }
slog.Warn(c.name+" failed, restarting", "err", err) slog.Warn(c.name+" failed, restarting", "err", err)
time.Sleep(time.Second * 5) time.Sleep(retryWaitTime)
continue continue
} }
} }
@@ -75,7 +98,7 @@ func (c *gpuCollector) collect() error {
scanner := bufio.NewScanner(stdout) scanner := bufio.NewScanner(stdout)
if c.buf == nil { if c.buf == nil {
c.buf = make([]byte, 0, 4*1024) c.buf = make([]byte, 0, cmdBufferSize)
} }
scanner.Buffer(c.buf, bufio.MaxScanTokenSize) scanner.Buffer(c.buf, bufio.MaxScanTokenSize)
@@ -110,28 +133,28 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
if !ok { if !ok {
return true return true
} }
data := string(output)
// Parse RAM usage // Parse RAM usage
ramMatches := ramPattern.FindStringSubmatch(data) ramMatches := ramPattern.FindSubmatch(output)
if ramMatches != nil { if ramMatches != nil {
gpuData.MemoryUsed, _ = strconv.ParseFloat(ramMatches[1], 64) gpuData.MemoryUsed, _ = strconv.ParseFloat(string(ramMatches[1]), 64)
gpuData.MemoryTotal, _ = strconv.ParseFloat(ramMatches[2], 64) gpuData.MemoryTotal, _ = strconv.ParseFloat(string(ramMatches[2]), 64)
} }
// Parse GR3D (GPU) usage // Parse GR3D (GPU) usage
gr3dMatches := gr3dPattern.FindStringSubmatch(data) gr3dMatches := gr3dPattern.FindSubmatch(output)
if gr3dMatches != nil { if gr3dMatches != nil {
gpuData.Usage, _ = strconv.ParseFloat(gr3dMatches[1], 64) gr3dUsage, _ := strconv.ParseFloat(string(gr3dMatches[1]), 64)
gpuData.Usage += gr3dUsage
} }
// Parse temperature // Parse temperature
tempMatches := tempPattern.FindStringSubmatch(data) tempMatches := tempPattern.FindSubmatch(output)
if tempMatches != nil { if tempMatches != nil {
gpuData.Temperature, _ = strconv.ParseFloat(tempMatches[1], 64) gpuData.Temperature, _ = strconv.ParseFloat(string(tempMatches[1]), 64)
} }
// Parse power usage // Parse power usage
powerMatches := powerPattern.FindStringSubmatch(data) powerMatches := powerPattern.FindSubmatch(output)
if powerMatches != nil { if powerMatches != nil {
power, _ := strconv.ParseFloat(powerMatches[2], 64) power, _ := strconv.ParseFloat(string(powerMatches[2]), 64)
gpuData.Power = power / 1000 gpuData.Power += power / milliwattsInAWatt
} }
gpuData.Count++ gpuData.Count++
return true return true
@@ -142,8 +165,10 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
func (gm *GPUManager) parseNvidiaData(output []byte) bool { func (gm *GPUManager) parseNvidiaData(output []byte) bool {
gm.Lock() gm.Lock()
defer gm.Unlock() defer gm.Unlock()
scanner := bufio.NewScanner(bytes.NewReader(output))
var valid bool 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), ", ") fields := strings.Split(strings.TrimSpace(line), ", ")
if len(fields) < 7 { if len(fields) < 7 {
continue continue
@@ -169,8 +194,8 @@ func (gm *GPUManager) parseNvidiaData(output []byte) bool {
// update gpu data // update gpu data
gpu := gm.GpuDataMap[id] gpu := gm.GpuDataMap[id]
gpu.Temperature = temp gpu.Temperature = temp
gpu.MemoryUsed = memoryUsage / 1.024 gpu.MemoryUsed = memoryUsage / mebibytesInAMegabyte
gpu.MemoryTotal = totalMemory / 1.024 gpu.MemoryTotal = totalMemory / mebibytesInAMegabyte
gpu.Usage += usage gpu.Usage += usage
gpu.Power += power gpu.Power += power
gpu.Count++ gpu.Count++
@@ -241,6 +266,7 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
} }
gpuData[id] = gpuCopy gpuData[id] = gpuCopy
} }
slog.Debug("GPU", "data", gpuData)
return gpuData return gpuData
} }
@@ -249,13 +275,13 @@ func (gm *GPUManager) GetCurrentData() map[string]system.GPUData {
// tools are found. If none of the tools are found, it returns an error indicating that no GPU // tools are found. If none of the tools are found, it returns an error indicating that no GPU
// management tools are available. // management tools are available.
func (gm *GPUManager) detectGPUs() error { func (gm *GPUManager) detectGPUs() error {
if _, err := exec.LookPath("nvidia-smi"); err == nil { if _, err := exec.LookPath(nvidiaSmiCmd); err == nil {
gm.nvidiaSmi = true gm.nvidiaSmi = true
} }
if _, err := exec.LookPath("rocm-smi"); err == nil { if _, err := exec.LookPath(rocmSmiCmd); err == nil {
gm.rocmSmi = true gm.rocmSmi = true
} }
if _, err := exec.LookPath("tegrastats"); err == nil { if _, err := exec.LookPath(tegraStatsCmd); err == nil {
gm.tegrastats = true gm.tegrastats = true
} }
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats { if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
@@ -270,17 +296,17 @@ func (gm *GPUManager) startCollector(command string) {
name: command, name: command,
} }
switch command { switch command {
case "nvidia-smi": case nvidiaSmiCmd:
collector.cmdArgs = []string{"-l", "4", collector.cmdArgs = []string{"-l", nvidiaSmiInterval,
"--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw", "--query-gpu=index,name,temperature.gpu,memory.used,memory.total,utilization.gpu,power.draw",
"--format=csv,noheader,nounits"} "--format=csv,noheader,nounits"}
collector.parse = gm.parseNvidiaData collector.parse = gm.parseNvidiaData
go collector.start() go collector.start()
case "tegrastats": case tegraStatsCmd:
collector.cmdArgs = []string{"--interval", "3000"} collector.cmdArgs = []string{"--interval", tegraStatsInterval}
collector.parse = gm.getJetsonParser() collector.parse = gm.getJetsonParser()
go collector.start() go collector.start()
case "rocm-smi": case rocmSmiCmd:
collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"} collector.cmdArgs = []string{"--showid", "--showtemp", "--showuse", "--showpower", "--showproductname", "--showmeminfo", "vram", "--json"}
collector.parse = gm.parseAmdData collector.parse = gm.parseAmdData
go func() { go func() {
@@ -288,12 +314,12 @@ func (gm *GPUManager) startCollector(command string) {
for { for {
if err := collector.collect(); err != nil { if err := collector.collect(); err != nil {
failures++ failures++
if failures > 5 { if failures > maxFailureRetries {
break break
} }
slog.Warn("Error collecting AMD GPU data", "err", err) slog.Warn("Error collecting AMD GPU data", "err", err)
} }
time.Sleep(4300 * time.Millisecond) time.Sleep(rocmSmiInterval)
} }
}() }()
} }
@@ -308,13 +334,13 @@ func NewGPUManager() (*GPUManager, error) {
gm.GpuDataMap = make(map[string]*system.GPUData) gm.GpuDataMap = make(map[string]*system.GPUData)
if gm.nvidiaSmi { if gm.nvidiaSmi {
gm.startCollector("nvidia-smi") gm.startCollector(nvidiaSmiCmd)
} }
if gm.rocmSmi { if gm.rocmSmi {
gm.startCollector("rocm-smi") gm.startCollector(rocmSmiCmd)
} }
if gm.tegrastats { if gm.tegrastats {
gm.startCollector("tegrastats") gm.startCollector(tegraStatsCmd)
} }
return &gm, nil return &gm, nil

View File

@@ -1,3 +1,6 @@
//go:build testing
// +build testing
package agent package agent
import ( import (
@@ -43,6 +46,52 @@ func TestParseNvidiaData(t *testing.T) {
}, },
wantValid: true, wantValid: true,
}, },
{
name: "more valid multi-gpu data",
input: `0, NVIDIA A10, 45, 19676, 23028, 0, 58.98
1, NVIDIA A10, 45, 19638, 23028, 0, 62.35
2, NVIDIA A10, 44, 21700, 23028, 0, 59.57
3, NVIDIA A10, 45, 18222, 23028, 0, 61.76`,
wantData: map[string]system.GPUData{
"0": {
Name: "A10",
Temperature: 45.0,
MemoryUsed: 19676.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 58.98,
Count: 1,
},
"1": {
Name: "A10",
Temperature: 45.0,
MemoryUsed: 19638.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 62.35,
Count: 1,
},
"2": {
Name: "A10",
Temperature: 44.0,
MemoryUsed: 21700.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 59.57,
Count: 1,
},
"3": {
Name: "A10",
Temperature: 45.0,
MemoryUsed: 18222.0 / 1.024,
MemoryTotal: 23028.0 / 1.024,
Usage: 0.0,
Power: 61.76,
Count: 1,
},
},
wantValid: true,
},
{ {
name: "empty input", name: "empty input",
input: "", input: "",
@@ -207,7 +256,7 @@ func TestParseJetsonData(t *testing.T) {
}{ }{
{ {
name: "valid data", name: "valid data",
input: "RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW", input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% tj@52.468C VDD_GPU_SOC 2171mW",
wantMetrics: &system.GPUData{ wantMetrics: &system.GPUData{
Name: "Jetson", Name: "Jetson",
MemoryUsed: 4300.0, MemoryUsed: 4300.0,
@@ -218,9 +267,22 @@ func TestParseJetsonData(t *testing.T) {
Count: 1, Count: 1,
}, },
}, },
{
name: "more valid data",
input: "11-15-2024 08:38:09 RAM 6185/7620MB (lfb 8x2MB) SWAP 851/3810MB (cached 1MB) CPU [15%@729,11%@729,14%@729,13%@729,11%@729,8%@729] EMC_FREQ 43%@2133 GR3D_FREQ 63%@[621] NVDEC off NVJPG off NVJPG1 off VIC off OFA off APE 200 cpu@53.968C soc2@52.437C soc0@50.75C gpu@53.343C tj@53.968C soc1@51.656C VDD_IN 12479mW/12479mW VDD_CPU_GPU_CV 4667mW/4667mW VDD_SOC 2817mW/2817mW",
wantMetrics: &system.GPUData{
Name: "Jetson",
MemoryUsed: 6185.0,
MemoryTotal: 7620.0,
Usage: 63.0,
Temperature: 53.968,
Power: 4.667,
Count: 1,
},
},
{ {
name: "missing temperature", name: "missing temperature",
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW", input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
wantMetrics: &system.GPUData{ wantMetrics: &system.GPUData{
Name: "Jetson", Name: "Jetson",
MemoryUsed: 4300.0, MemoryUsed: 4300.0,
@@ -232,7 +294,7 @@ func TestParseJetsonData(t *testing.T) {
}, },
{ {
name: "no gpu defined by nvidia-smi", name: "no gpu defined by nvidia-smi",
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW", input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
gm: &GPUManager{ gm: &GPUManager{
GpuDataMap: map[string]*system.GPUData{}, GpuDataMap: map[string]*system.GPUData{},
}, },
@@ -486,7 +548,7 @@ echo '{"card0": {"Temperature (Sensor edge) (C)": "49.0", "Current Socket Graphi
setup: func(t *testing.T) error { setup: func(t *testing.T) error {
path := filepath.Join(dir, "tegrastats") path := filepath.Join(dir, "tegrastats")
script := `#!/bin/sh script := `#!/bin/sh
echo "RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"` echo "11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
if err := os.WriteFile(path, []byte(script), 0755); err != nil { if err := os.WriteFile(path, []byte(script), 0755); err != nil {
return err return err
} }
@@ -523,3 +585,158 @@ echo "RAM 1024/4096MB GR3D_FREQ 80% tj@70C VDD_GPU_SOC 1000mW"`
}) })
} }
} }
// TestAccumulationTableDriven tests the accumulation behavior for all three GPU types
func TestAccumulation(t *testing.T) {
type expectedGPUValues struct {
temperature float64
memoryUsed float64
memoryTotal float64
usage float64
power float64
count float64
avgUsage float64
avgPower float64
}
tests := []struct {
name string
initialGPUData map[string]*system.GPUData
dataSamples [][]byte
parser func(*GPUManager) func([]byte) bool
expectedValues map[string]expectedGPUValues
}{
{
name: "Jetson GPU accumulation",
initialGPUData: map[string]*system.GPUData{
"0": {
Name: "Jetson",
Temperature: 0,
Usage: 0,
Power: 0,
Count: 0,
},
},
dataSamples: [][]byte{
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 30% tj@50.5C VDD_GPU_SOC 1000mW"),
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 40% tj@60.5C VDD_GPU_SOC 1200mW"),
[]byte("11-14-2024 22:54:33 RAM 1024/4096MB GR3D_FREQ 50% tj@70.5C VDD_GPU_SOC 1400mW"),
},
parser: func(gm *GPUManager) func([]byte) bool {
return gm.getJetsonParser()
},
expectedValues: map[string]expectedGPUValues{
"0": {
temperature: 70.5, // Last value
memoryUsed: 1024, // Last value
memoryTotal: 4096, // Last value
usage: 120.0, // Accumulated: 30 + 40 + 50
power: 3.6, // Accumulated: 1.0 + 1.2 + 1.4
count: 3,
avgUsage: 40.0, // 120 / 3
avgPower: 1.2, // 3.6 / 3
},
},
},
{
name: "NVIDIA GPU accumulation",
initialGPUData: map[string]*system.GPUData{
// NVIDIA parser will create the GPU data entries
},
dataSamples: [][]byte{
[]byte("0, NVIDIA GeForce RTX 3080, 50, 5000, 10000, 30, 200"),
[]byte("0, NVIDIA GeForce RTX 3080, 60, 6000, 10000, 40, 250"),
[]byte("0, NVIDIA GeForce RTX 3080, 70, 7000, 10000, 50, 300"),
},
parser: func(gm *GPUManager) func([]byte) bool {
return gm.parseNvidiaData
},
expectedValues: map[string]expectedGPUValues{
"0": {
temperature: 70.0, // Last value
memoryUsed: 7000.0 / 1.024, // Last value
memoryTotal: 10000.0 / 1.024, // Last value
usage: 120.0, // Accumulated: 30 + 40 + 50
power: 750.0, // Accumulated: 200 + 250 + 300
count: 3,
avgUsage: 40.0, // 120 / 3
avgPower: 250.0, // 750 / 3
},
},
},
{
name: "AMD GPU accumulation",
initialGPUData: map[string]*system.GPUData{
// AMD parser will create the GPU data entries
},
dataSamples: [][]byte{
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "50.0", "Current Socket Graphics Package Power (W)": "100.0", "GPU use (%)": "30", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "1073741824", "Card Series": "Radeon RX 6800"}}`),
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "60.0", "Current Socket Graphics Package Power (W)": "150.0", "GPU use (%)": "40", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "2147483648", "Card Series": "Radeon RX 6800"}}`),
[]byte(`{"card0": {"GUID": "34756", "Temperature (Sensor edge) (C)": "70.0", "Current Socket Graphics Package Power (W)": "200.0", "GPU use (%)": "50", "VRAM Total Memory (B)": "10737418240", "VRAM Total Used Memory (B)": "3221225472", "Card Series": "Radeon RX 6800"}}`),
},
parser: func(gm *GPUManager) func([]byte) bool {
return gm.parseAmdData
},
expectedValues: map[string]expectedGPUValues{
"34756": {
temperature: 70.0, // Last value
memoryUsed: 3221225472.0 / (1024 * 1024), // Last value
memoryTotal: 10737418240.0 / (1024 * 1024), // Last value
usage: 120.0, // Accumulated: 30 + 40 + 50
power: 450.0, // Accumulated: 100 + 150 + 200
count: 3,
avgUsage: 40.0, // 120 / 3
avgPower: 150.0, // 450 / 3
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Create a new GPUManager for each test
gm := &GPUManager{
GpuDataMap: tt.initialGPUData,
}
// Get the parser function
parser := tt.parser(gm)
// Process each data sample
for i, sample := range tt.dataSamples {
valid := parser(sample)
assert.True(t, valid, "Sample %d should be valid", i)
}
// Check accumulated values
for id, expected := range tt.expectedValues {
gpu, exists := gm.GpuDataMap[id]
assert.True(t, exists, "GPU with ID %s should exist", id)
if !exists {
continue
}
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature should match")
assert.InDelta(t, expected.memoryUsed, gpu.MemoryUsed, 0.01, "Memory used should match")
assert.InDelta(t, expected.memoryTotal, gpu.MemoryTotal, 0.01, "Memory total should match")
assert.InDelta(t, expected.usage, gpu.Usage, 0.01, "Usage should match")
assert.InDelta(t, expected.power, gpu.Power, 0.01, "Power should match")
assert.Equal(t, expected.count, gpu.Count, "Count should match")
}
// Verify average calculation in GetCurrentData
result := gm.GetCurrentData()
for id, expected := range tt.expectedValues {
gpu, exists := result[id]
assert.True(t, exists, "GPU with ID %s should exist in GetCurrentData result", id)
if !exists {
continue
}
assert.InDelta(t, expected.temperature, gpu.Temperature, 0.01, "Temperature in GetCurrentData should match")
assert.InDelta(t, expected.avgUsage, gpu.Usage, 0.01, "Average usage in GetCurrentData should match")
assert.InDelta(t, expected.avgPower, gpu.Power, 0.01, "Average power in GetCurrentData should match")
}
})
}
}

View File

@@ -0,0 +1,18 @@
package agent
import (
"net"
"time"
)
// Health checks if the agent's server is running by attempting to connect to it.
//
// If an error occurs when attempting to connect to the server, it returns the error.
func Health(addr string, network string) error {
conn, err := net.DialTimeout(network, addr, 4*time.Second)
if err != nil {
return err
}
conn.Close()
return nil
}

View File

@@ -0,0 +1,118 @@
//go:build testing
// +build testing
package agent_test
import (
"fmt"
"net"
"os"
"testing"
"github.com/stretchr/testify/require"
"beszel/internal/agent"
)
// setupTestServer creates a temporary server for testing
func setupTestServer(t *testing.T) (string, func()) {
// Create a temporary socket file for Unix socket testing
tempSockFile := os.TempDir() + "/beszel_health_test.sock"
// Clean up any existing socket file
os.Remove(tempSockFile)
// Create a listener
listener, err := net.Listen("unix", tempSockFile)
require.NoError(t, err, "Failed to create test listener")
// Start a simple server in a goroutine
go func() {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
defer conn.Close()
// Just accept the connection and do nothing
}()
// Return the socket file path and a cleanup function
return tempSockFile, func() {
listener.Close()
os.Remove(tempSockFile)
}
}
// setupTCPTestServer creates a temporary TCP server for testing
func setupTCPTestServer(t *testing.T) (string, func()) {
// Listen on a random available port
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err, "Failed to create test listener")
// Get the port that was assigned
addr := listener.Addr().(*net.TCPAddr)
port := addr.Port
// Start a simple server in a goroutine
go func() {
conn, err := listener.Accept()
if err != nil {
return // Listener closed
}
defer conn.Close()
// Just accept the connection and do nothing
}()
// Return the address and a cleanup function
return fmt.Sprintf("127.0.0.1:%d", port), func() {
listener.Close()
}
}
func TestHealth(t *testing.T) {
t.Run("server is running (unix socket)", func(t *testing.T) {
// Setup a test server
sockFile, cleanup := setupTestServer(t)
defer cleanup()
// Run the health check with explicit parameters
err := agent.Health(sockFile, "unix")
require.NoError(t, err, "Failed to check health")
})
t.Run("server is running (tcp address)", func(t *testing.T) {
// Setup a test server
addr, cleanup := setupTCPTestServer(t)
defer cleanup()
// Run the health check with explicit parameters
err := agent.Health(addr, "tcp")
require.NoError(t, err, "Failed to check health")
})
t.Run("server is not running", func(t *testing.T) {
// Use an address that's likely not in use
addr := "127.0.0.1:65535"
// Run the health check with explicit parameters
err := agent.Health(addr, "tcp")
require.Error(t, err, "Health check should return an error when server is not running")
})
t.Run("invalid network", func(t *testing.T) {
// Use an invalid network type
err := agent.Health("127.0.0.1:8080", "invalid_network")
require.Error(t, err, "Health check should return an error with invalid network")
})
t.Run("unix socket not found", func(t *testing.T) {
// Use a non-existent unix socket
nonExistentSocket := os.TempDir() + "/non_existent_socket.sock"
// Make sure it really doesn't exist
os.Remove(nonExistentSocket)
err := agent.Health(nonExistentSocket, "unix")
require.Error(t, err, "Health check should return an error when socket doesn't exist")
})
}

View File

@@ -17,7 +17,7 @@ func (a *Agent) initializeNetIoStats() {
nics, nicsEnvExists := GetEnv("NICS") nics, nicsEnvExists := GetEnv("NICS")
if nicsEnvExists { if nicsEnvExists {
nicsMap = make(map[string]struct{}, 0) nicsMap = make(map[string]struct{}, 0)
for _, nic := range strings.Split(nics, ",") { for nic := range strings.SplitSeq(nics, ",") {
nicsMap[nic] = struct{}{} nicsMap[nic] = struct{}{}
} }
} }

View File

@@ -23,20 +23,14 @@ func (a *Agent) StartServer(opts ServerOptions) error {
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network) slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
switch opts.Network { if opts.Network == "unix" {
case "unix":
// remove existing socket file if it exists // remove existing socket file if it exists
if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) { if err := os.Remove(opts.Addr); err != nil && !os.IsNotExist(err) {
return err return err
} }
default:
// prefix with : if only port was provided
if !strings.Contains(opts.Addr, ":") {
opts.Addr = ":" + opts.Addr
}
} }
// Listen on the address // start listening on the address
ln, err := net.Listen(opts.Network, opts.Addr) ln, err := net.Listen(opts.Network, opts.Addr)
if err != nil { if err != nil {
return err return err
@@ -44,7 +38,7 @@ func (a *Agent) StartServer(opts ServerOptions) error {
defer ln.Close() defer ln.Close()
// Start SSH server on the listener // Start SSH server on the listener
err = sshServer.Serve(ln, nil, sshServer.NoPty(), return sshServer.Serve(ln, nil, sshServer.NoPty(),
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool { sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
for _, pubKey := range opts.Keys { for _, pubKey := range opts.Keys {
if sshServer.KeysEqual(key, pubKey) { if sshServer.KeysEqual(key, pubKey) {
@@ -54,15 +48,11 @@ func (a *Agent) StartServer(opts ServerOptions) error {
return false return false
}), }),
) )
if err != nil {
return err
}
return nil
} }
func (a *Agent) handleSession(s sshServer.Session) { func (a *Agent) handleSession(s sshServer.Session) {
// slog.Debug("connection", "remoteaddr", s.RemoteAddr(), "user", s.User()) slog.Debug("New session", "client", s.RemoteAddr())
stats := a.gatherStats() stats := a.gatherStats(s.Context().SessionID())
if err := json.NewEncoder(s).Encode(stats); err != nil { if err := json.NewEncoder(s).Encode(stats); err != nil {
slog.Error("Error encoding stats", "err", err, "stats", stats) slog.Error("Error encoding stats", "err", err, "stats", stats)
s.Exit(1) s.Exit(1)
@@ -74,24 +64,48 @@ func (a *Agent) handleSession(s sshServer.Session) {
// It returns a slice of ssh.PublicKey and an error if any key fails to parse. // It returns a slice of ssh.PublicKey and an error if any key fails to parse.
func ParseKeys(input string) ([]ssh.PublicKey, error) { func ParseKeys(input string) ([]ssh.PublicKey, error) {
var parsedKeys []ssh.PublicKey var parsedKeys []ssh.PublicKey
for line := range strings.Lines(input) { for line := range strings.Lines(input) {
line = strings.TrimSpace(line) line = strings.TrimSpace(line)
// Skip empty lines or comments // Skip empty lines or comments
if len(line) == 0 || strings.HasPrefix(line, "#") { if len(line) == 0 || strings.HasPrefix(line, "#") {
continue continue
} }
// Parse the key // Parse the key
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line)) parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err) return nil, fmt.Errorf("failed to parse key: %s, error: %w", line, err)
} }
// Append the parsed key to the list
parsedKeys = append(parsedKeys, parsedKey) parsedKeys = append(parsedKeys, parsedKey)
} }
return parsedKeys, nil return parsedKeys, nil
} }
// GetAddress gets the address to listen on or connect to from environment variables or default value.
func GetAddress(addr string) string {
if addr == "" {
addr, _ = GetEnv("LISTEN")
}
if addr == "" {
// Legacy PORT environment variable support
addr, _ = GetEnv("PORT")
}
if addr == "" {
return ":45876"
}
// prefix with : if only port was provided
if GetNetwork(addr) != "unix" && !strings.Contains(addr, ":") {
addr = ":" + addr
}
return addr
}
// GetNetwork returns the network type to use based on the address
func GetNetwork(addr string) string {
if network, ok := GetEnv("NETWORK"); ok && network != "" {
return network
}
if strings.HasPrefix(addr, "/") {
return "unix"
}
return "tcp"
}

View File

@@ -45,7 +45,7 @@ func TestStartServer(t *testing.T) {
name: "tcp port only", name: "tcp port only",
config: ServerOptions{ config: ServerOptions{
Network: "tcp", Network: "tcp",
Addr: "45987", Addr: ":45987",
Keys: []ssh.PublicKey{sshPubKey}, Keys: []ssh.PublicKey{sshPubKey},
}, },
}, },
@@ -88,7 +88,7 @@ func TestStartServer(t *testing.T) {
name: "bad key should fail", name: "bad key should fail",
config: ServerOptions{ config: ServerOptions{
Network: "tcp", Network: "tcp",
Addr: "45987", Addr: ":45987",
Keys: []ssh.PublicKey{sshBadPubKey}, Keys: []ssh.PublicKey{sshBadPubKey},
}, },
wantErr: true, wantErr: true,
@@ -98,7 +98,7 @@ func TestStartServer(t *testing.T) {
name: "good key still good", name: "good key still good",
config: ServerOptions{ config: ServerOptions{
Network: "tcp", Network: "tcp",
Addr: "45987", Addr: ":45987",
Keys: []ssh.PublicKey{sshPubKey}, Keys: []ssh.PublicKey{sshPubKey},
}, },
}, },

View File

@@ -184,11 +184,9 @@ func (a *Agent) getSystemStats() system.Stats {
} }
} }
// temperatures (skip if sensors whitelist is set to empty string) // temperatures
err = a.updateTemperatures(&systemStats) // TODO: maybe refactor to methods on systemStats
if err != nil { a.updateTemperatures(&systemStats)
slog.Error("Error getting temperatures", "err", err)
}
// GPU data // GPU data
if a.gpuManager != nil { if a.gpuManager != nil {
@@ -205,6 +203,9 @@ func (a *Agent) getSystemStats() system.Stats {
for _, gpu := range gpuData { for _, gpu := range gpuData {
if gpu.Temperature > 0 { if gpu.Temperature > 0 {
systemStats.Temperatures[gpu.Name] = gpu.Temperature systemStats.Temperatures[gpu.Name] = gpu.Temperature
if a.primarySensor == gpu.Name {
a.systemInfo.DashboardTemp = gpu.Temperature
}
} }
// update high gpu percent for dashboard // update high gpu percent for dashboard
a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage) a.systemInfo.GpuPct = max(a.systemInfo.GpuPct, gpu.Usage)
@@ -223,28 +224,23 @@ func (a *Agent) getSystemStats() system.Stats {
return systemStats return systemStats
} }
func (a *Agent) updateTemperatures(systemStats *system.Stats) error { func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// skip if sensors whitelist is set to empty string // skip if sensors whitelist is set to empty string
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 { if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
slog.Debug("Skipping temperature collection") slog.Debug("Skipping temperature collection")
return nil return
} }
primarySensor, primarySensorIsDefined := GetEnv("PRIMARY_SENSOR")
// reset high temp // reset high temp
a.systemInfo.DashboardTemp = 0 a.systemInfo.DashboardTemp = 0
// get sensor data // get sensor data
temps, err := sensors.TemperaturesWithContext(a.sensorsContext) temps, _ := sensors.TemperaturesWithContext(a.sensorsContext)
if err != nil {
return err
}
slog.Debug("Temperature", "sensors", temps) slog.Debug("Temperature", "sensors", temps)
// return if no sensors // return if no sensors
if len(temps) == 0 { if len(temps) == 0 {
return nil return
} }
systemStats.Temperatures = make(map[string]float64, len(temps)) systemStats.Temperatures = make(map[string]float64, len(temps))
@@ -265,16 +261,13 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) error {
} }
} }
// set dashboard temperature // set dashboard temperature
if primarySensorIsDefined { if a.primarySensor == "" {
if sensorName == primarySensor {
a.systemInfo.DashboardTemp = sensor.Temperature
}
} else {
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature) a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
} else if a.primarySensor == sensorName {
a.systemInfo.DashboardTemp = sensor.Temperature
} }
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature) systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
} }
return nil
} }
// Returns the size of the ZFS ARC memory cache in bytes // Returns the size of the ZFS ARC memory cache in bytes

View File

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

View File

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

View File

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

View File

@@ -6,7 +6,6 @@ import (
"log" "log"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
@@ -22,12 +21,13 @@ type Config struct {
type SystemConfig struct { type SystemConfig struct {
Name string `yaml:"name"` Name string `yaml:"name"`
Host string `yaml:"host"` Host string `yaml:"host"`
Port uint16 `yaml:"port"` Port uint16 `yaml:"port,omitempty"`
Users []string `yaml:"users"` Users []string `yaml:"users"`
} }
// Syncs systems with the config.yml file // Syncs systems with the config.yml file
func (h *Hub) syncSystemsWithConfig() error { func syncSystemsWithConfig(e *core.ServeEvent) error {
h := e.App
configPath := filepath.Join(h.DataDir(), "config.yml") configPath := filepath.Join(h.DataDir(), "config.yml")
configData, err := os.ReadFile(configPath) configData, err := os.ReadFile(configPath)
if err != nil { if err != nil {
@@ -89,16 +89,16 @@ func (h *Hub) syncSystemsWithConfig() error {
return err return err
} }
// Create a map of existing systems for easy lookup // Create a map of existing systems
existingSystemsMap := make(map[string]*core.Record) existingSystemsMap := make(map[string]*core.Record)
for _, system := range existingSystems { for _, system := range existingSystems {
key := system.GetString("host") + ":" + system.GetString("port") key := system.GetString("name") + system.GetString("host") + system.GetString("port")
existingSystemsMap[key] = system existingSystemsMap[key] = system
} }
// Process systems from config // Process systems from config
for _, sysConfig := range config.Systems { for _, sysConfig := range config.Systems {
key := sysConfig.Host + ":" + strconv.Itoa(int(sysConfig.Port)) key := sysConfig.Name + sysConfig.Host + cast.ToString(sysConfig.Port)
if existingSystem, ok := existingSystemsMap[key]; ok { if existingSystem, ok := existingSystemsMap[key]; ok {
// Update existing system // Update existing system
existingSystem.Set("name", sysConfig.Name) existingSystem.Set("name", sysConfig.Name)

View File

@@ -4,67 +4,46 @@ package hub
import ( import (
"beszel" "beszel"
"beszel/internal/alerts" "beszel/internal/alerts"
"beszel/internal/entities/system" "beszel/internal/hub/systems"
"beszel/internal/records" "beszel/internal/records"
"beszel/internal/users" "beszel/internal/users"
"beszel/site" "beszel/site"
"context"
"crypto/ed25519" "crypto/ed25519"
"encoding/pem" "encoding/pem"
"fmt"
"io/fs" "io/fs"
"log"
"net"
"net/http" "net/http"
"net/http/httputil" "net/http/httputil"
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time"
"github.com/goccy/go-json"
"github.com/pocketbase/pocketbase" "github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/plugins/migratecmd"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
) )
type Hub struct { type Hub struct {
*pocketbase.PocketBase core.App
sshClientConfig *ssh.ClientConfig *alerts.AlertManager
pubKey string um *users.UserManager
am *alerts.AlertManager rm *records.RecordManager
um *users.UserManager sm *systems.SystemManager
rm *records.RecordManager pubKey string
systemStats *core.Collection appURL string
containerStats *core.Collection
appURL string
} }
// NewHub creates a new Hub instance with default configuration // NewHub creates a new Hub instance with default configuration
func NewHub() *Hub { func NewHub(app core.App) *Hub {
var hub Hub hub := &Hub{}
hub.PocketBase = pocketbase.NewWithConfig(pocketbase.Config{ hub.App = app
DefaultDataDir: beszel.AppName + "_data",
})
hub.RootCmd.Version = beszel.Version hub.AlertManager = alerts.NewAlertManager(hub)
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.um = users.NewUserManager(hub) hub.um = users.NewUserManager(hub)
hub.rm = records.NewRecordManager(hub) hub.rm = records.NewRecordManager(hub)
hub.sm = systems.NewSystemManager(hub)
hub.appURL, _ = GetEnv("APP_URL") 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. // GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
@@ -76,444 +55,192 @@ func GetEnv(key string) (value string, exists bool) {
return os.LookupEnv(key) return os.LookupEnv(key)
} }
func (h *Hub) Run() { func (h *Hub) StartHub() error {
isDev := os.Getenv("ENV") == "dev"
// enable auto creation of migration files when making collection changes in the Admin UI h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
migratecmd.MustRegister(h, h.RootCmd, migratecmd.Config{ // initialize settings / collections
// (the isDev check is to enable it only during development) if err := h.initialize(e); err != nil {
Automigrate: isDev,
Dir: "../../migrations",
})
// initial setup
h.OnServe().BindFunc(func(se *core.ServeEvent) error {
// create ssh client config
err := h.createSSHClientConfig()
if err != nil {
log.Fatal(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 return err
} }
// sync systems with config // sync systems with config
h.syncSystemsWithConfig() if err := syncSystemsWithConfig(e); err != nil {
return se.Next() return err
})
// 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() // register api routes
}) if err := h.registerApiRoutes(e); err != nil {
return err
// set up scheduled jobs / ticker for system updates }
h.OnServe().BindFunc(func(se *core.ServeEvent) error { // register cron jobs
// 15 second ticker for system updates if err := h.registerCronJobs(e); err != nil {
go h.startSystemUpdateTicker() return err
// set up cron jobs }
// delete old records once every hour // start server
h.Cron().MustAdd("delete old records", "8 * * * *", h.rm.DeleteOldRecords) if err := h.startServer(e); err != nil {
// create longer records every 10 minutes return err
h.Cron().MustAdd("create longer records", "*/10 * * * *", func() { }
if systemStats, containerStats, err := h.getCollections(); err == nil { // start system updates
h.rm.CreateLongerRecords([]*core.Collection{systemStats, containerStats}) if err := h.sm.Initialize(); err != nil {
} return err
})
return se.Next()
})
// 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() return e.Next()
}) })
// TODO: move to users package
// handle default values for user / user_settings creation // handle default values for user / user_settings creation
h.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole) h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
h.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings) h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
// empty info for systems that are paused if pb, ok := h.App.(*pocketbase.PocketBase); ok {
h.OnRecordUpdate("systems").BindFunc(func(e *core.RecordEvent) error { // log.Println("Starting pocketbase")
if e.Record.GetString("status") == "paused" { err := pb.Start()
e.Record.Set("info", system.Info{})
}
return e.Next()
})
// 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 err != nil {
if record.GetString("status") != "down" { return err
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 {
return nil, err
}
return client, 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
}
// Create the Signer for this private key.
signer, err := ssh.ParsePrivateKey(key)
if err != nil {
return err
}
h.sshClientConfig = &ssh.ClientConfig{
User: "u",
Auth: []ssh.AuthMethod{
ssh.PublicKeys(signer),
},
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
Timeout: 4 * time.Second,
}
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")
}
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 nil
} }
// Adds timeout to SSH session creation to avoid hanging in case of network issues // initialize sets up initial configuration (collections, settings, etc.)
func newSessionWithTimeout(client *ssh.Client, timeout time.Duration) (*ssh.Session, error) { func (h *Hub) initialize(e *core.ServeEvent) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout) // set general settings
defer cancel() settings := e.App.Settings()
// batch requests (for global alerts)
// use goroutine to create the session settings.Batch.Enabled = true
sessionChan := make(chan *ssh.Session, 1) // set URL if BASE_URL env is set
errChan := make(chan error, 1) if h.appURL != "" {
go func() { settings.Meta.AppURL = h.appURL
if session, err := 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("session creation timed out")
} }
// set auth settings
usersCollection, err := e.App.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 := e.App.Save(usersCollection); err != nil {
return err
}
// allow all users to access systems if SHARE_ALL_SYSTEMS is set
systemsCollection, err := e.App.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 := e.App.Save(systemsCollection); err != nil {
return err
}
return nil
} }
func (h *Hub) getSSHKey() ([]byte, error) { // startServer starts the server for the Beszel (not PocketBase)
func (h *Hub) startServer(se *core.ServeEvent) error {
// TODO: exclude dev server from production binary
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
}
// 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)
indexContent = strings.Replace(indexContent, "{{V}}", beszel.Version, 1)
// 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 nil
}
// registerCronJobs sets up scheduled tasks
func (h *Hub) registerCronJobs(_ *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 nil
}
// 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 nil
}
// generates key pair if it doesn't exist and returns private key bytes
func (h *Hub) GetSSHKey() ([]byte, error) {
dataDir := h.DataDir() dataDir := h.DataDir()
// check if the key pair already exists // check if the key pair already exists
existingKey, err := os.ReadFile(dataDir + "/id_ed25519") 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 ( import (
"beszel/internal/entities/container" "beszel/internal/entities/container"
"beszel/internal/entities/system" "beszel/internal/entities/system"
"fmt"
"log" "log"
"math" "math"
"strings"
"time" "time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
"github.com/pocketbase/dbx" "github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
) )
type RecordManager struct { type RecordManager struct {
@@ -25,11 +26,6 @@ type LongerRecordData struct {
minShorterRecords int minShorterRecords int
} }
type RecordDeletionData struct {
recordType string
retention time.Duration
}
type RecordStats []struct { type RecordStats []struct {
Stats []byte `db:"stats"` Stats []byte `db:"stats"`
} }
@@ -39,7 +35,7 @@ func NewRecordManager(app core.App) *RecordManager {
} }
// Create longer records by averaging shorter records // Create longer records by averaging shorter records
func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) { func (rm *RecordManager) CreateLongerRecords() {
// start := time.Now() // start := time.Now()
longerRecordData := []LongerRecordData{ longerRecordData := []LongerRecordData{
{ {
@@ -70,14 +66,24 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
} }
// wrap the operations in a transaction // wrap the operations in a transaction
rm.app.RunInTransaction(func(txApp core.App) error { 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 { if err != nil {
log.Println("failed to get active systems", "err", err.Error())
return err 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 // loop through all active systems, time periods, and collections
for _, system := range activeSystems { for _, system := range systems {
// log.Println("processing system", system.GetString("name")) // log.Println("processing system", system.GetString("name"))
for i := range longerRecordData { for i := range longerRecordData {
recordData := longerRecordData[i] recordData := longerRecordData[i]
@@ -92,7 +98,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
if recordData.longerType != "10m" { if recordData.longerType != "10m" {
lastLongerRecord, err := txApp.FindFirstRecordByFilter( lastLongerRecord, err := txApp.FindFirstRecordByFilter(
collection.Id, collection.Id,
"type = {:type} && system = {:system} && created > {:created}", "system = {:system} && type = {:type} && created > {:created}",
dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod}, dbx.Params{"type": recordData.longerType, "system": system.Id, "created": longerRecordPeriod},
) )
// continue if longer record exists // continue if longer record exists
@@ -108,7 +114,7 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
Select("stats"). Select("stats").
From(collection.Name). From(collection.Name).
AndWhere(dbx.NewExp( AndWhere(dbx.NewExp(
"type={:type} AND system={:system} AND created > {:created}", "system={:system} AND type={:type} AND created > {:created}",
dbx.Params{ dbx.Params{
"type": recordData.shorterType, "type": recordData.shorterType,
"system": system.Id, "system": system.Id,
@@ -119,7 +125,6 @@ func (rm *RecordManager) CreateLongerRecords(collections []*core.Collection) {
// continue if not enough shorter records // continue if not enough shorter records
if err != nil || len(stats) < recordData.minShorterRecords { if err != nil || len(stats) < recordData.minShorterRecords {
// log.Println("not enough shorter records. continue.", len(allShorterRecords), recordData.expectedShorterRecords)
continue continue
} }
// average the shorter records and create longer record // 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)) longerRecord.Set("stats", rm.AverageContainerStats(stats))
} }
if err := txApp.SaveNoValidate(longerRecord); err != nil { 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 // Calculate the average stats of a list of system_stats records without reflect
func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats { func (rm *RecordManager) AverageSystemStats(records RecordStats) *system.Stats {
sum := system.Stats{} sum := &system.Stats{}
count := float64(len(records)) count := float64(len(records))
// use different counter for temps in case some records don't have them
tempCount := float64(0) tempCount := float64(0)
var stats system.Stats // Temporary struct for unmarshaling
stats := &system.Stats{}
// Accumulate totals
for i := range records { for i := range records {
stats = system.Stats{} // Zero the struct before unmarshalling *stats = system.Stats{} // Reset tempStats for unmarshaling
json.Unmarshal(records[i].Stats, &stats) if err := json.Unmarshal(records[i].Stats, stats); err != nil {
continue
}
sum.Cpu += stats.Cpu sum.Cpu += stats.Cpu
sum.Mem += stats.Mem sum.Mem += stats.Mem
sum.MemUsed += stats.MemUsed sum.MemUsed += stats.MemUsed
@@ -171,26 +180,25 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
sum.DiskWritePs += stats.DiskWritePs sum.DiskWritePs += stats.DiskWritePs
sum.NetworkSent += stats.NetworkSent sum.NetworkSent += stats.NetworkSent
sum.NetworkRecv += stats.NetworkRecv sum.NetworkRecv += stats.NetworkRecv
// set peak values // Set peak values
sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu) sum.MaxCpu = max(sum.MaxCpu, stats.MaxCpu, stats.Cpu)
sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent) sum.MaxNetworkSent = max(sum.MaxNetworkSent, stats.MaxNetworkSent, stats.NetworkSent)
sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv) sum.MaxNetworkRecv = max(sum.MaxNetworkRecv, stats.MaxNetworkRecv, stats.NetworkRecv)
sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs) sum.MaxDiskReadPs = max(sum.MaxDiskReadPs, stats.MaxDiskReadPs, stats.DiskReadPs)
sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs) sum.MaxDiskWritePs = max(sum.MaxDiskWritePs, stats.MaxDiskWritePs, stats.DiskWritePs)
// add temps to sum
// Accumulate temperatures
if stats.Temperatures != nil { if stats.Temperatures != nil {
if sum.Temperatures == nil { if sum.Temperatures == nil {
sum.Temperatures = make(map[string]float64, len(stats.Temperatures)) sum.Temperatures = make(map[string]float64, len(stats.Temperatures))
} }
tempCount++ tempCount++
for key, value := range stats.Temperatures { for key, value := range stats.Temperatures {
if _, ok := sum.Temperatures[key]; !ok {
sum.Temperatures[key] = 0
}
sum.Temperatures[key] += value sum.Temperatures[key] += value
} }
} }
// add extra fs to sum
// Accumulate extra filesystem stats
if stats.ExtraFs != nil { if stats.ExtraFs != nil {
if sum.ExtraFs == nil { if sum.ExtraFs == nil {
sum.ExtraFs = make(map[string]*system.FsStats, len(stats.ExtraFs)) 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 { if _, ok := sum.ExtraFs[key]; !ok {
sum.ExtraFs[key] = &system.FsStats{} sum.ExtraFs[key] = &system.FsStats{}
} }
sum.ExtraFs[key].DiskTotal += value.DiskTotal fs := sum.ExtraFs[key]
sum.ExtraFs[key].DiskUsed += value.DiskUsed fs.DiskTotal += value.DiskTotal
sum.ExtraFs[key].DiskWritePs += value.DiskWritePs fs.DiskUsed += value.DiskUsed
sum.ExtraFs[key].DiskReadPs += value.DiskReadPs fs.DiskWritePs += value.DiskWritePs
// peak values fs.DiskReadPs += value.DiskReadPs
sum.ExtraFs[key].MaxDiskReadPS = max(sum.ExtraFs[key].MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs) fs.MaxDiskReadPS = max(fs.MaxDiskReadPS, value.MaxDiskReadPS, value.DiskReadPs)
sum.ExtraFs[key].MaxDiskWritePS = max(sum.ExtraFs[key].MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs) fs.MaxDiskWritePS = max(fs.MaxDiskWritePS, value.MaxDiskWritePS, value.DiskWritePs)
} }
} }
// add GPU data
// Accumulate GPU data
if stats.GPUData != nil { if stats.GPUData != nil {
if sum.GPUData == nil { if sum.GPUData == nil {
sum.GPUData = make(map[string]system.GPUData, len(stats.GPUData)) sum.GPUData = make(map[string]system.GPUData, len(stats.GPUData))
} }
for id, value := range stats.GPUData { for id, value := range stats.GPUData {
if _, ok := sum.GPUData[id]; !ok { gpu, ok := sum.GPUData[id]
sum.GPUData[id] = system.GPUData{Name: value.Name} if !ok {
gpu = system.GPUData{Name: value.Name}
} }
gpu := sum.GPUData[id]
gpu.Temperature += value.Temperature gpu.Temperature += value.Temperature
gpu.MemoryUsed += value.MemoryUsed gpu.MemoryUsed += value.MemoryUsed
gpu.MemoryTotal += value.MemoryTotal gpu.MemoryTotal += value.MemoryTotal
@@ -229,76 +238,67 @@ func (rm *RecordManager) AverageSystemStats(records RecordStats) system.Stats {
} }
} }
stats = system.Stats{ // Compute averages in place
Cpu: twoDecimals(sum.Cpu / count), if count > 0 {
Mem: twoDecimals(sum.Mem / count), sum.Cpu = twoDecimals(sum.Cpu / count)
MemUsed: twoDecimals(sum.MemUsed / count), sum.Mem = twoDecimals(sum.Mem / count)
MemPct: twoDecimals(sum.MemPct / count), sum.MemUsed = twoDecimals(sum.MemUsed / count)
MemBuffCache: twoDecimals(sum.MemBuffCache / count), sum.MemPct = twoDecimals(sum.MemPct / count)
MemZfsArc: twoDecimals(sum.MemZfsArc / count), sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
Swap: twoDecimals(sum.Swap / count), sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
SwapUsed: twoDecimals(sum.SwapUsed / count), sum.Swap = twoDecimals(sum.Swap / count)
DiskTotal: twoDecimals(sum.DiskTotal / count), sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
DiskUsed: twoDecimals(sum.DiskUsed / count), sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
DiskPct: twoDecimals(sum.DiskPct / count), sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
DiskReadPs: twoDecimals(sum.DiskReadPs / count), sum.DiskPct = twoDecimals(sum.DiskPct / count)
DiskWritePs: twoDecimals(sum.DiskWritePs / count), sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
NetworkSent: twoDecimals(sum.NetworkSent / count), sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
NetworkRecv: twoDecimals(sum.NetworkRecv / count), sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
MaxCpu: sum.MaxCpu, sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
MaxDiskReadPs: sum.MaxDiskReadPs,
MaxDiskWritePs: sum.MaxDiskWritePs,
MaxNetworkSent: sum.MaxNetworkSent,
MaxNetworkRecv: sum.MaxNetworkRecv,
}
if sum.Temperatures != nil { // Average temperatures
stats.Temperatures = make(map[string]float64, len(sum.Temperatures)) if sum.Temperatures != nil && tempCount > 0 {
for key, value := range sum.Temperatures { for key := range sum.Temperatures {
stats.Temperatures[key] = twoDecimals(value / tempCount) sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
}
} }
}
if sum.ExtraFs != nil { // Average extra filesystem stats
stats.ExtraFs = make(map[string]*system.FsStats, len(sum.ExtraFs)) if sum.ExtraFs != nil {
for key, value := range sum.ExtraFs { for key := range sum.ExtraFs {
stats.ExtraFs[key] = &system.FsStats{ fs := sum.ExtraFs[key]
DiskTotal: twoDecimals(value.DiskTotal / count), fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
DiskUsed: twoDecimals(value.DiskUsed / count), fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
DiskWritePs: twoDecimals(value.DiskWritePs / count), fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
DiskReadPs: twoDecimals(value.DiskReadPs / count), fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
MaxDiskReadPS: value.MaxDiskReadPS, }
MaxDiskWritePS: value.MaxDiskWritePS, }
// 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 { return sum
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
} }
// Calculate the average stats of a list of container_stats records // Calculate the average stats of a list of container_stats records
func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats { func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.Stats {
sums := make(map[string]*container.Stats) sums := make(map[string]*container.Stats)
count := float64(len(records)) count := float64(len(records))
containerStats := make([]container.Stats, 0, 50)
var containerStats []container.Stats
for i := range records { for i := range records {
// Reset the slice length to 0, but keep the capacity // reset slice
containerStats = containerStats[:0] containerStats = containerStats[:0]
if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil { if err := json.Unmarshal(records[i].Stats, &containerStats); err != nil {
return []container.Stats{} return []container.Stats{}
@@ -330,38 +330,45 @@ func (rm *RecordManager) AverageContainerStats(records RecordStats) []container.
// Deletes records older than what is displayed in the UI // Deletes records older than what is displayed in the UI
func (rm *RecordManager) DeleteOldRecords() { func (rm *RecordManager) DeleteOldRecords() {
// Define the collections to process
collections := []string{"system_stats", "container_stats"} collections := []string{"system_stats", "container_stats"}
recordData := []RecordDeletionData{
{ // Define record types and their retention periods
recordType: "1m", type RecordDeletionData struct {
retention: time.Hour, recordType string
}, retention time.Duration
{
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,
},
} }
db := rm.app.NonconcurrentDB() recordData := []RecordDeletionData{
for _, recordData := range recordData { {recordType: "1m", retention: time.Hour}, // 1 hour
for _, collectionSlug := range collections { {recordType: "10m", retention: 12 * time.Hour}, // 12 hours
formattedDate := time.Now().UTC().Add(-recordData.retention).Format(types.DefaultDateLayout) {recordType: "20m", retention: 24 * time.Hour}, // 1 day
expr := dbx.NewExp("[[created]] < {:date} AND [[type]] = {:type}", dbx.Params{"date": formattedDate, "type": recordData.recordType}) {recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
_, err := db.Delete(collectionSlug, expr).Execute() {recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
if err != nil { }
rm.app.Logger().Error("Failed to delete records", "err", err.Error())
} // 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,676 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
// delete duplicate alerts
app.DB().NewQuery(`
DELETE FROM alerts
WHERE rowid NOT IN (
SELECT MAX(rowid)
FROM alerts
GROUP BY user, system, name
);
`).Execute()
// import collections
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 UNIQUE INDEX ` + "`" + `idx_MnhEt21L5r` + "`" + ` ON ` + "`" + `alerts` + "`" + ` (\n ` + "`" + `user` + "`" + `,\n ` + "`" + `system` + "`" + `,\n ` + "`" + `name` + "`" + `\n)"
],
"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.

View File

@@ -6,7 +6,12 @@
<link rel="icon" type="image/svg+xml" href="./static/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="./static/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Beszel</title> <title>Beszel</title>
<script>window.BASE_PATH = "%BASE_URL%"</script> <script>
globalThis.BESZEL = {
BASE_PATH: "%BASE_URL%",
HUB_VERSION: "{{V}}"
}
</script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -1,6 +1,6 @@
import type { LinguiConfig } from "@lingui/conf" import { defineConfig } from "@lingui/cli"
const config: LinguiConfig = { export default defineConfig({
locales: [ locales: [
"en", "en",
"ar", "ar",
@@ -39,6 +39,4 @@ const config: LinguiConfig = {
include: ["src"], include: ["src"],
}, },
], ],
} })
export default config

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "beszel", "name": "beszel",
"private": true, "private": true,
"version": "0.0.0", "version": "0.10.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -12,9 +12,10 @@
}, },
"dependencies": { "dependencies": {
"@henrygd/queue": "^1.0.7", "@henrygd/queue": "^1.0.7",
"@lingui/detect-locale": "^4.14.1", "@henrygd/semaphore": "^0.0.2",
"@lingui/macro": "^4.14.1", "@lingui/detect-locale": "^5.2.0",
"@lingui/react": "^4.14.1", "@lingui/macro": "^5.2.0",
"@lingui/react": "^5.2.0",
"@nanostores/react": "^0.7.3", "@nanostores/react": "^0.7.3",
"@nanostores/router": "^0.11.0", "@nanostores/router": "^0.11.0",
"@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-alert-dialog": "^1.1.6",
@@ -31,35 +32,35 @@
"@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-tabs": "^1.1.3",
"@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toast": "^1.2.6",
"@radix-ui/react-tooltip": "^1.1.8", "@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", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.0.4", "cmdk": "^1.0.4",
"d3-time": "^3.1.0", "d3-time": "^3.1.0",
"lucide-react": "^0.452.0", "lucide-react": "^0.452.0",
"nanostores": "^0.11.3", "nanostores": "^0.11.4",
"pocketbase": "^0.25.1", "pocketbase": "^0.25.2",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"recharts": "^2.15.1", "recharts": "^2.15.1",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"valibot": "^0.36.0" "valibot": "^0.42.0"
}, },
"devDependencies": { "devDependencies": {
"@lingui/cli": "^4.14.1", "@lingui/cli": "^5.2.0",
"@lingui/swc-plugin": "^4.1.0", "@lingui/swc-plugin": "^5.5.0",
"@lingui/vite-plugin": "^4.14.1", "@lingui/vite-plugin": "^5.2.0",
"@types/bun": "^1.2.2", "@types/bun": "^1.2.4",
"@types/react": "^18.3.18", "@types/react": "^18.3.1",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react-swc": "^3.7.2", "@vitejs/plugin-react-swc": "^3.8.0",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.4.20",
"postcss": "^8.5.1", "postcss": "^8.5.3",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.17",
"tailwindcss-rtl": "^0.9.0", "tailwindcss-rtl": "^0.9.0",
"typescript": "^5.7.3", "typescript": "^5.8.2",
"vite": "^5.4.14" "vite": "^6.2.0"
}, },
"overrides": { "overrides": {
"@nanostores/router": { "@nanostores/router": {

View File

@@ -1,3 +1,5 @@
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import {
Dialog, Dialog,
@@ -14,9 +16,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { $publicKey, pb } from "@/lib/stores" 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 { i18n } from "@lingui/core"
import { t, Trans } from "@lingui/macro"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react" import { ChevronDownIcon, Copy, PlusIcon } from "lucide-react"
import { memo, useRef, useState } from "react" import { memo, useRef, useState } from "react"
@@ -61,13 +62,13 @@ function copyDockerCompose(port = "45876", publicKey: string) {
# monitor other disks / partitions by mounting a folder in /extra-filesystems # monitor other disks / partitions by mounting a folder in /extra-filesystems
# - /mnt/disk/.beszel:/extra-filesystems/sda1:ro # - /mnt/disk/.beszel:/extra-filesystems/sda1:ro
environment: environment:
PORT: ${port} LISTEN: ${port}
KEY: "${publicKey}"`) KEY: "${publicKey}"`)
} }
function copyDockerRun(port = "45876", publicKey: string) { function copyDockerRun(port = "45876", publicKey: string) {
copyToClipboard( 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 +92,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
const port = useRef<HTMLInputElement>(null) const port = useRef<HTMLInputElement>(null)
const [hostValue, setHostValue] = useState(system?.host ?? "") const [hostValue, setHostValue] = useState(system?.host ?? "")
const isUnixSocket = hostValue.startsWith("/") const isUnixSocket = hostValue.startsWith("/")
const [tab, setTab] = useLocalStorage("as-tab", "docker")
async function handleSubmit(e: SubmitEvent) { async function handleSubmit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@@ -118,7 +120,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
setHostValue(system?.host ?? "") setHostValue(system?.host ?? "")
}} }}
> >
<Tabs defaultValue="docker"> <Tabs defaultValue={tab} onValueChange={setTab}>
<DialogHeader> <DialogHeader>
<DialogTitle className="mb-2"> <DialogTitle className="mb-2">
{system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>} {system ? `${t`Edit`} ${system?.name}` : <Trans>Add New System</Trans>}
@@ -140,7 +142,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
</DialogDescription> </DialogDescription>
</TabsContent> </TabsContent>
{/* Binary */} {/* Binary */}
<TabsContent value="binary"> <TabsContent value="binary" tabIndex={-1}>
<DialogDescription className="mb-4 leading-normal w-0 min-w-full"> <DialogDescription className="mb-4 leading-normal w-0 min-w-full">
<Trans> <Trans>
The agent must be running on the system to connect. Copy the installation command for the agent below. The agent must be running on the system to connect. Copy the installation command for the agent below.
@@ -259,12 +261,12 @@ const CopyButton = memo((props: CopyButtonProps) => {
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
{props.dropdownUrl ? ( {props.dropdownUrl ? (
<DropdownMenuItem asChild> <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} {props.dropdownText}
</a> </a>
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem onClick={props.dropdownOnClick}>{props.dropdownText}</DropdownMenuItem> <DropdownMenuItem onClick={props.dropdownOnClick} className="cursor-pointer">{props.dropdownText}</DropdownMenuItem>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@@ -1,6 +1,8 @@
import { memo, useState } from "react" import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { memo, useMemo, useState } from "react"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { $alerts, $systems } from "@/lib/stores" import { $alerts } from "@/lib/stores"
import { import {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
@@ -17,104 +19,114 @@ import { Link } from "../router"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Checkbox } from "../ui/checkbox" import { Checkbox } from "../ui/checkbox"
import { SystemAlert, SystemAlertGlobal } from "./alerts-system" import { SystemAlert, SystemAlertGlobal } from "./alerts-system"
import { Trans, t } from "@lingui/macro"
export default memo(function AlertsButton({ system }: { system: SystemRecord }) { export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts) const alerts = useStore($alerts)
const [opened, setOpened] = useState(false) const [opened, setOpened] = useState(false)
const systemAlerts = alerts.filter((alert) => alert.system === system.id) as AlertRecord[] const hasAlert = alerts.some((alert) => alert.system === system.id)
const active = systemAlerts.length > 0
return ( return useMemo(
<Dialog> () => (
<DialogTrigger asChild> <Dialog>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}> <DialogTrigger asChild>
<BellIcon <Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", { <BellIcon
"fill-primary": active, className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
})} "fill-primary": hasAlert,
/> })}
</Button> />
</DialogTrigger> </Button>
<DialogContent className="max-h-full overflow-auto max-w-[35rem]"> </DialogTrigger>
{opened && <TheContent data={{ system, alerts, systemAlerts }} />} <DialogContent className="max-h-full overflow-auto max-w-[35rem]">
</DialogContent> {opened && <AlertDialogContent system={system} />}
</Dialog> </DialogContent>
</Dialog>
),
[opened, hasAlert]
) )
}) })
function TheContent({ function AlertDialogContent({ system }: { system: SystemRecord }) {
data: { system, alerts, systemAlerts }, const alerts = useStore($alerts)
}: {
data: { system: SystemRecord; alerts: AlertRecord[]; systemAlerts: AlertRecord[] }
}) {
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false) const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const systems = $systems.get()
const data = Object.keys(alertInfo).map((key) => { // alertsSignature changes only when alerts for this system change
const alert = alertInfo[key as keyof typeof alertInfo] let alertsSignature = ""
return { const systemAlerts = alerts.filter((alert) => {
key: key as keyof typeof alertInfo, if (alert.system === system.id) {
alert, alertsSignature += alert.name + alert.min + alert.value
system, return true
} }
}) return false
}) as AlertRecord[]
return ( return useMemo(() => {
<> // console.log("render modal", system.name, alertsSignature)
<DialogHeader> const data = Object.keys(alertInfo).map((name) => {
<DialogTitle className="text-xl"> const alert = alertInfo[name as keyof typeof alertInfo]
<Trans>Alerts</Trans> return {
</DialogTitle> name: name as keyof typeof alertInfo,
<DialogDescription> alert,
<Trans> system,
See{" "} }
<Link href="/settings/notifications" className="link"> })
notification settings
</Link>{" "} return (
to configure how you receive alerts. <>
</Trans> <DialogHeader>
</DialogDescription> <DialogTitle className="text-xl">
</DialogHeader> <Trans>Alerts</Trans>
<Tabs defaultValue="system"> </DialogTitle>
<TabsList className="mb-1 -mt-0.5"> <DialogDescription>
<TabsTrigger value="system"> <Trans>
<ServerIcon className="me-2 h-3.5 w-3.5" /> See{" "}
{system.name} <Link href="/settings/notifications" className="link">
</TabsTrigger> notification settings
<TabsTrigger value="global"> </Link>{" "}
<GlobeIcon className="me-1.5 h-3.5 w-3.5" /> to configure how you receive alerts.
<Trans>All Systems</Trans> </Trans>
</TabsTrigger> </DialogDescription>
</TabsList> </DialogHeader>
<TabsContent value="system"> <Tabs defaultValue="system">
<div className="grid gap-3"> <TabsList className="mb-1 -mt-0.5">
{data.map((d) => ( <TabsTrigger value="system">
<SystemAlert key={d.key} system={system} data={d} systemAlerts={systemAlerts} /> <ServerIcon className="me-2 h-3.5 w-3.5" />
))} {system.name}
</div> </TabsTrigger>
</TabsContent> <TabsTrigger value="global">
<TabsContent value="global"> <GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<label <Trans>All Systems</Trans>
htmlFor="ovw" </TabsTrigger>
className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm" </TabsList>
> <TabsContent value="system">
<Checkbox <div className="grid gap-3">
id="ovw" {data.map((d) => (
className="text-destructive border-destructive data-[state=checked]:bg-destructive" <SystemAlert key={d.name} system={system} data={d} systemAlerts={systemAlerts} />
checked={overwriteExisting} ))}
onCheckedChange={setOverwriteExisting} </div>
/> </TabsContent>
<Trans>Overwrite existing alerts</Trans> <TabsContent value="global">
</label> <label
<div className="grid gap-3"> htmlFor="ovw"
{data.map((d) => ( className="mb-3 flex gap-2 items-center justify-center cursor-pointer border rounded-sm py-3 px-4 border-destructive text-destructive font-semibold text-sm"
<SystemAlertGlobal key={d.key} data={d} overwrite={overwriteExisting} alerts={alerts} systems={systems} /> >
))} <Checkbox
</div> id="ovw"
</TabsContent> className="text-destructive border-destructive data-[state=checked]:bg-destructive"
</Tabs> checked={overwriteExisting}
</> onCheckedChange={setOverwriteExisting}
) />
<Trans>Overwrite existing alerts</Trans>
</label>
<div className="grid gap-3">
{data.map((d) => (
<SystemAlertGlobal key={d.name} data={d} overwrite={overwriteExisting} />
))}
</div>
</TabsContent>
</Tabs>
</>
)
}, [alertsSignature, overwriteExisting])
} }

View File

@@ -1,18 +1,20 @@
import { pb } from "@/lib/stores" import { t } from "@lingui/core/macro"
import { Trans, Plural } from "@lingui/react/macro"
import { $alerts, $systems, pb } from "@/lib/stores"
import { alertInfo, cn } from "@/lib/utils" import { alertInfo, cn } from "@/lib/utils"
import { Switch } from "@/components/ui/switch" import { Switch } from "@/components/ui/switch"
import { AlertInfo, AlertRecord, SystemRecord } from "@/types" import { AlertInfo, AlertRecord, SystemRecord } from "@/types"
import { lazy, Suspense, useRef, useState } from "react" import { lazy, Suspense, useMemo, useState } from "react"
import { toast } from "../ui/use-toast" import { toast } from "../ui/use-toast"
import { RecordOptions } from "pocketbase" import { BatchService } from "pocketbase"
import { Trans, t, Plural } from "@lingui/macro" import { getSemaphore } from "@henrygd/semaphore"
interface AlertData { interface AlertData {
checked?: boolean checked?: boolean
val?: number val?: number
min?: number min?: number
updateAlert?: (checked: boolean, value: number, min: number) => void updateAlert?: (checked: boolean, value: number, min: number) => void
key: keyof typeof alertInfo name: keyof typeof alertInfo
alert: AlertInfo alert: AlertInfo
system: SystemRecord system: SystemRecord
} }
@@ -35,7 +37,7 @@ export function SystemAlert({
systemAlerts: AlertRecord[] systemAlerts: AlertRecord[]
data: AlertData data: AlertData
}) { }) {
const alert = systemAlerts.find((alert) => alert.name === data.key) const alert = systemAlerts.find((alert) => alert.name === data.name)
data.updateAlert = async (checked: boolean, value: number, min: number) => { data.updateAlert = async (checked: boolean, value: number, min: number) => {
try { try {
@@ -47,7 +49,7 @@ export function SystemAlert({
pb.collection("alerts").create({ pb.collection("alerts").create({
system: system.id, system: system.id,
user: pb.authStore.record!.id, user: pb.authStore.record!.id,
name: data.key, name: data.name,
value: value, value: value,
min: min, min: min,
}) })
@@ -66,99 +68,150 @@ export function SystemAlert({
return <AlertContent data={data} /> return <AlertContent data={data} />
} }
export function SystemAlertGlobal({ export const SystemAlertGlobal = ({ data, overwrite }: { data: AlertData; overwrite: boolean | "indeterminate" }) => {
data,
overwrite,
alerts,
systems,
}: {
data: AlertData
overwrite: boolean | "indeterminate"
alerts: AlertRecord[]
systems: SystemRecord[]
}) {
const systemsWithExistingAlerts = useRef<{ set: Set<string>; populatedSet: boolean }>({
set: new Set(),
populatedSet: false,
})
data.checked = false data.checked = false
data.val = data.min = 0 data.val = data.min = 0
// set of system ids that have an alert for this name when the component is mounted
const existingAlertsSystems = useMemo(() => {
const map = new Set<string>()
const alerts = $alerts.get()
for (const alert of alerts) {
if (alert.name === data.name) {
map.add(alert.system)
}
}
return map
}, [])
data.updateAlert = async (checked: boolean, value: number, min: number) => { data.updateAlert = async (checked: boolean, value: number, min: number) => {
const { set, populatedSet } = systemsWithExistingAlerts.current const sem = getSemaphore("alerts")
await sem.acquire()
try {
// if another update is waiting behind, don't start this one
if (sem.size() > 1) {
return
}
// if overwrite checked, make sure all alerts will be overwritten const recordData: Partial<AlertRecord> = {
if (overwrite) { value,
set.clear() min,
} triggered: false,
}
const recordData: Partial<AlertRecord> = { const batch = batchWrapper("alerts", 25)
value, const systems = $systems.get()
min, const currentAlerts = $alerts.get()
triggered: false,
}
// we can only send 50 in one batch // map of current alerts with this name right now by system id
let done = 0 const currentAlertsSystems = new Map<string, AlertRecord>()
for (const alert of currentAlerts) {
while (done < systems.length) { if (alert.name === data.name) {
const batch = pb.createBatch() currentAlertsSystems.set(alert.system, alert)
let batchSize = 0
for (let i = done; i < Math.min(done + 50, systems.length); i++) {
const system = systems[i]
// if overwrite is false and system is in set (alert existed), skip
if (!overwrite && set.has(system.id)) {
continue
} }
// find matching existing alert }
const existingAlert = alerts.find((alert) => alert.system === system.id && data.key === alert.name)
// if first run, add system to set (alert already existed when global panel was opened) if (overwrite) {
if (existingAlert && !populatedSet && !overwrite) { existingAlertsSystems.clear()
set.add(system.id) }
continue
} const processSystem = async (system: SystemRecord): Promise<void> => {
batchSize++ const existingAlert = existingAlertsSystems.has(system.id)
const requestOptions: RecordOptions = {
requestKey: system.id, if (!overwrite && existingAlert) {
return
} }
// checked - make sure alert is created or updated const currentAlert = currentAlertsSystems.get(system.id)
// delete existing alert if unchecked
if (!checked && currentAlert) {
return batch.remove(currentAlert.id)
}
if (checked && currentAlert) {
// update existing alert if checked
return batch.update(currentAlert.id, recordData)
}
if (checked) { if (checked) {
if (existingAlert) { // create new alert if checked and not existing
batch.collection("alerts").update(existingAlert.id, recordData, requestOptions) return batch.create({
} else { system: system.id,
batch.collection("alerts").create( user: pb.authStore.record!.id,
{ name: data.name,
system: system.id, ...recordData,
user: pb.authStore.record!.id, })
name: data.key,
...recordData,
},
requestOptions
)
}
} else if (existingAlert) {
batch.collection("alerts").delete(existingAlert.id)
} }
} }
try {
batchSize && batch.send() // make sure current system is updated in the first batch
} catch (e) { await processSystem(data.system)
failedUpdateToast() for (const system of systems) {
} finally { if (system.id === data.system.id) {
done += 50 continue
}
if (sem.size() > 1) {
return
}
await processSystem(system)
} }
await batch.send()
} finally {
sem.release()
} }
systemsWithExistingAlerts.current.populatedSet = true
} }
return <AlertContent data={data} /> return <AlertContent data={data} />
} }
/**
* Creates a wrapper for performing batch operations on a specified collection.
*/
function batchWrapper(collection: string, batchSize: number) {
let batch: BatchService | undefined
let count = 0
const create = async <T extends Record<string, any>>(options: T) => {
batch ||= pb.createBatch()
batch.collection(collection).create(options)
if (++count >= batchSize) {
await send()
}
}
const update = async <T extends Record<string, any>>(id: string, data: T) => {
batch ||= pb.createBatch()
batch.collection(collection).update(id, data)
if (++count >= batchSize) {
await send()
}
}
const remove = async (id: string) => {
batch ||= pb.createBatch()
batch.collection(collection).delete(id)
if (++count >= batchSize) {
await send()
}
}
const send = async () => {
if (count) {
await batch?.send({ requestKey: null })
batch = undefined
count = 0
}
}
return {
update,
remove,
send,
create,
}
}
function AlertContent({ data }: { data: AlertData }) { function AlertContent({ data }: { data: AlertData }) {
const { key } = data const { name } = data
const singleDescription = data.alert.singleDesc?.() const singleDescription = data.alert.singleDesc?.()
@@ -166,17 +219,12 @@ function AlertContent({ data }: { data: AlertData }) {
const [min, setMin] = useState(data.min || 10) const [min, setMin] = useState(data.min || 10)
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80)) const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
const newMin = useRef(min) const Icon = alertInfo[name].icon
const newValue = useRef(value)
const Icon = alertInfo[key].icon
const updateAlert = (c?: boolean) => data.updateAlert?.(c ?? checked, newValue.current, newMin.current)
return ( return (
<div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group"> <div className="rounded-lg border border-muted-foreground/15 hover:border-muted-foreground/20 transition-colors duration-100 group">
<label <label
htmlFor={`s${key}`} htmlFor={`s${name}`}
className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", { className={cn("flex flex-row items-center justify-between gap-4 cursor-pointer p-4", {
"pb-0": checked, "pb-0": checked,
})} })}
@@ -188,56 +236,67 @@ function AlertContent({ data }: { data: AlertData }) {
{!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>} {!checked && <span className="block text-sm text-muted-foreground">{data.alert.desc()}</span>}
</div> </div>
<Switch <Switch
id={`s${key}`} id={`s${name}`}
checked={checked} checked={checked}
onCheckedChange={(checked) => { onCheckedChange={(newChecked) => {
setChecked(checked) setChecked(newChecked)
updateAlert(checked) data.updateAlert?.(newChecked, value, min)
}} }}
/> />
</label> </label>
{checked && ( {checked && (
<div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground"> <div className="grid sm:grid-cols-2 mt-1.5 gap-5 px-4 pb-5 tabular-nums text-muted-foreground">
<Suspense fallback={<div className="h-10" />}> <Suspense fallback={<div className="h-10" />}>
{!singleDescription && ( {!singleDescription && (
<div> <div>
<p id={`v${key}`} className="text-sm block h-8"> <p id={`v${name}`} className="text-sm block h-8">
<Trans> <Trans>
Average exceeds{" "} Average exceeds{" "}
<strong className="text-foreground"> <strong className="text-foreground">
{value} {value}
{data.alert.unit} {data.alert.unit}
</strong> </strong>
</Trans> </Trans>
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Slider <Slider
aria-labelledby={`v${key}`} aria-labelledby={`v${name}`}
defaultValue={[value]} defaultValue={[value]}
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()} onValueCommit={(val) => {
onValueChange={(val) => setValue(val[0])} data.updateAlert?.(true, val[0], min)
min={1} }}
max={alertInfo[key].max ?? 99} onValueChange={(val) => {
/> setValue(val[0])
}}
min={1}
max={alertInfo[name].max ?? 99}
/>
</div>
</div> </div>
</div> )}
)}
<div className={cn(singleDescription && "col-span-full lowercase")}> <div className={cn(singleDescription && "col-span-full lowercase")}>
<p id={`t${key}`} className="text-sm block h-8 first-letter:uppercase"> <p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
{singleDescription && ( {singleDescription && (
<>{singleDescription}{` `}</> <>
{singleDescription}
{` `}
</>
)} )}
<Trans> <Trans>
For <strong className="text-foreground">{min}</strong>{" "} For <strong className="text-foreground">{min}</strong>{" "}
<Plural value={min} one=" minute" other=" minutes" /> <Plural value={min} one="minute" other="minutes" />
</Trans> </Trans>
</p> </p>
<div className="flex gap-3"> <div className="flex gap-3">
<Slider <Slider
aria-labelledby={`v${key}`} aria-labelledby={`v${name}`}
defaultValue={[min]} defaultValue={[min]}
onValueCommit={(val) => (newMin.current = val[0]) && updateAlert()} onValueCommit={(min) => {
onValueChange={(val) => setMin(val[0])} data.updateAlert?.(true, value, min[0])
}}
onValueChange={(val) => {
setMin(val[0])
}}
min={1} min={1}
max={60} max={60}
/> />

View File

@@ -1,5 +1,6 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { t } from "@lingui/core/macro"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
@@ -12,8 +13,7 @@ import {
// import Spinner from '../spinner' // import Spinner from '../spinner'
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo, useMemo } from "react" import { memo, useMemo } from "react"
import { t } from "@lingui/macro" import { useLingui } from "@lingui/react/macro"
import { useLingui } from "@lingui/react"
/** [label, key, color, opacity] */ /** [label, key, color, opacity] */
type DataKeys = [string, string, number, number] type DataKeys = [string, string, number, number]
@@ -35,6 +35,7 @@ export default memo(function AreaChartDefault({
chartData, chartData,
max, max,
tickFormatter, tickFormatter,
contentFormatter,
}: { }: {
maxToggled?: boolean maxToggled?: boolean
unit?: string unit?: string
@@ -42,6 +43,7 @@ export default memo(function AreaChartDefault({
chartData: ChartData chartData: ChartData
max?: number max?: number
tickFormatter?: (value: number) => string tickFormatter?: (value: number) => string
contentFormatter?: (value: number) => string
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { i18n } = useLingui() const { i18n } = useLingui()
@@ -115,7 +117,12 @@ export default memo(function AreaChartDefault({
content={ content={
<ChartTooltipContent <ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)} labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value) + unit} contentFormatter={({ value }) => {
if (contentFormatter) {
return contentFormatter(value)
}
return decimalString(value) + unit
}}
// indicator="line" // indicator="line"
/> />
} }

View File

@@ -1,5 +1,4 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
@@ -12,8 +11,7 @@ import {
} from "@/lib/utils" } from "@/lib/utils"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo } from "react" import { memo } from "react"
import { t } from "@lingui/macro" import { useLingui } from "@lingui/react/macro"
import { useLingui } from "@lingui/react"
export default memo(function DiskChart({ export default memo(function DiskChart({
dataKey, dataKey,
@@ -25,7 +23,7 @@ export default memo(function DiskChart({
chartData: ChartData chartData: ChartData
}) { }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { _ } = useLingui() const { t } = useLingui()
// round to nearest GB // round to nearest GB
if (diskSize >= 100) { if (diskSize >= 100) {
@@ -76,7 +74,7 @@ export default memo(function DiskChart({
/> />
<Area <Area
dataKey={dataKey} dataKey={dataKey}
name={_(t`Disk Usage`)} name={t`Disk Usage`}
type="monotoneX" type="monotoneX"
fill="hsl(var(--chart-4))" fill="hsl(var(--chart-4))"
fillOpacity={0.4} fillOpacity={0.4}

View File

@@ -1,15 +1,13 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils" import { useYAxisWidth, cn, toFixedFloat, decimalString, formatShortDate, chartMargin } from "@/lib/utils"
import { memo } from "react" import { memo } from "react"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { t } from "@lingui/macro" import { useLingui } from "@lingui/react/macro"
import { useLingui } from "@lingui/react"
export default memo(function MemChart({ chartData }: { chartData: ChartData }) { export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { _ } = useLingui() const { t } = useLingui()
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1) const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
@@ -62,7 +60,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
} }
/> />
<Area <Area
name={_(t`Used`)} name={t`Used`}
order={3} order={3}
dataKey="stats.mu" dataKey="stats.mu"
type="monotoneX" type="monotoneX"
@@ -86,7 +84,7 @@ export default memo(function MemChart({ chartData }: { chartData: ChartData }) {
/> />
)} )}
<Area <Area
name={_(t`Cache / Buffers`)} name={t`Cache / Buffers`}
order={1} order={1}
dataKey="stats.mb" dataKey="stats.mb"
type="monotoneX" type="monotoneX"

View File

@@ -1,5 +1,6 @@
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts" import { t } from "@lingui/core/macro";
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart" import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { import {
useYAxisWidth, useYAxisWidth,
@@ -11,7 +12,6 @@ import {
} from "@/lib/utils" } from "@/lib/utils"
import { ChartData } from "@/types" import { ChartData } from "@/types"
import { memo } from "react" import { memo } from "react"
import { t } from "@lingui/macro"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) { export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth() const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()

View File

@@ -19,17 +19,15 @@ import {
CommandSeparator, CommandSeparator,
CommandShortcut, CommandShortcut,
} from "@/components/ui/command" } from "@/components/ui/command"
import { useEffect } from "react" import { memo, useEffect, useMemo } from "react"
import { useStore } from "@nanostores/react"
import { $systems } from "@/lib/stores" 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 { $router, basePath, navigate } from "./router"
import { Trans, t } from "@lingui/macro" import { Trans } from "@lingui/react/macro"
import { t } from "@lingui/core/macro"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
export default function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) { export default memo(function CommandPalette({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
const systems = useStore($systems)
useEffect(() => { useEffect(() => {
const down = (e: KeyboardEvent) => { const down = (e: KeyboardEvent) => {
if (e.key === "k" && (e.metaKey || e.ctrlKey)) { if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
@@ -37,162 +35,163 @@ export default function CommandPalette({ open, setOpen }: { open: boolean; setOp
setOpen(!open) setOpen(!open)
} }
} }
return listen(document, "keydown", down)
document.addEventListener("keydown", down)
return () => document.removeEventListener("keydown", down)
}, [open, setOpen]) }, [open, setOpen])
return ( return useMemo(() => {
<CommandDialog open={open} onOpenChange={setOpen}> const systems = $systems.get()
<CommandInput placeholder={t`Search for systems or settings...`} /> return (
<CommandList> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandEmpty> <CommandInput placeholder={t`Search for systems or settings...`} />
<Trans>No results found.</Trans> <CommandList>
</CommandEmpty> <CommandEmpty>
{systems.length > 0 && ( <Trans>No results found.</Trans>
<> </CommandEmpty>
<CommandGroup> {systems.length > 0 && (
{systems.map((system) => ( <>
<CommandGroup>
{systems.map((system) => (
<CommandItem
key={system.id}
onSelect={() => {
navigate(getPagePath($router, "system", { name: system.name }))
setOpen(false)
}}
>
<Server className="me-2 h-4 w-4" />
<span>{system.name}</span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator className="mb-1.5" />
</>
)}
<CommandGroup heading={t`Pages / Settings`}>
<CommandItem
keywords={["home"]}
onSelect={() => {
navigate(basePath)
setOpen(false)
}}
>
<LayoutDashboard className="me-2 h-4 w-4" />
<span>
<Trans>Dashboard</Trans>
</span>
<CommandShortcut>
<Trans>Page</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "general" }))
setOpen(false)
}}
>
<SettingsIcon className="me-2 h-4 w-4" />
<span>
<Trans>Settings</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["alerts"]}
onSelect={() => {
navigate(getPagePath($router, "settings", { name: "notifications" }))
setOpen(false)
}}
>
<MailIcon className="me-2 h-4 w-4" />
<span>
<Trans>Notifications</Trans>
</span>
<CommandShortcut>
<Trans>Settings</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["help", "oauth", "oidc"]}
onSelect={() => {
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
<BookIcon className="me-2 h-4 w-4" />
<span>
<Trans>Documentation</Trans>
</span>
<CommandShortcut>beszel.dev</CommandShortcut>
</CommandItem>
</CommandGroup>
{isAdmin() && (
<>
<CommandSeparator className="mb-1.5" />
<CommandGroup heading={t`Admin`}>
<CommandItem <CommandItem
key={system.id} keywords={["pocketbase"]}
onSelect={() => { onSelect={() => {
navigate(getPagePath($router, "system", { name: system.name }))
setOpen(false) setOpen(false)
window.open("/_/", "_blank")
}} }}
> >
<Server className="me-2 h-4 w-4" /> <UsersIcon className="me-2 h-4 w-4" />
<span>{system.name}</span> <span>
<CommandShortcut>{getHostDisplayValue(system)}</CommandShortcut> <Trans>Users</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem> </CommandItem>
))} <CommandItem
</CommandGroup> onSelect={() => {
<CommandSeparator className="mb-1.5" /> setOpen(false)
</> window.open("/_/#/logs", "_blank")
)} }}
<CommandGroup heading={t`Pages / Settings`}> >
<CommandItem <LogsIcon className="me-2 h-4 w-4" />
keywords={["home"]} <span>
onSelect={() => { <Trans>Logs</Trans>
navigate(basePath) </span>
setOpen(false) <CommandShortcut>
}} <Trans>Admin</Trans>
> </CommandShortcut>
<LayoutDashboard className="me-2 h-4 w-4" /> </CommandItem>
<span> <CommandItem
<Trans>Dashboard</Trans> onSelect={() => {
</span> setOpen(false)
<CommandShortcut> window.open("/_/#/settings/backups", "_blank")
<Trans>Page</Trans> }}
</CommandShortcut> >
</CommandItem> <DatabaseBackupIcon className="me-2 h-4 w-4" />
<CommandItem <span>
onSelect={() => { <Trans>Backups</Trans>
navigate(getPagePath($router, "settings", { name: "general" })) </span>
setOpen(false) <CommandShortcut>
}} <Trans>Admin</Trans>
> </CommandShortcut>
<SettingsIcon className="me-2 h-4 w-4" /> </CommandItem>
<span> <CommandItem
<Trans>Settings</Trans> keywords={["email"]}
</span> onSelect={() => {
<CommandShortcut> setOpen(false)
<Trans>Settings</Trans> window.open("/_/#/settings/mail", "_blank")
</CommandShortcut> }}
</CommandItem> >
<CommandItem <MailIcon className="me-2 h-4 w-4" />
keywords={["alerts"]} <span>
onSelect={() => { <Trans>SMTP settings</Trans>
navigate(getPagePath($router, "settings", { name: "notifications" })) </span>
setOpen(false) <CommandShortcut>
}} <Trans>Admin</Trans>
> </CommandShortcut>
<MailIcon className="me-2 h-4 w-4" /> </CommandItem>
<span> </CommandGroup>
<Trans>Notifications</Trans> </>
</span> )}
<CommandShortcut> </CommandList>
<Trans>Settings</Trans> </CommandDialog>
</CommandShortcut> )
</CommandItem> }, [open])
<CommandItem })
keywords={["help", "oauth", "oidc"]}
onSelect={() => {
window.location.href = "https://beszel.dev/guide/what-is-beszel"
}}
>
<BookIcon className="me-2 h-4 w-4" />
<span>
<Trans>Documentation</Trans>
</span>
<CommandShortcut>beszel.dev</CommandShortcut>
</CommandItem>
</CommandGroup>
{isAdmin() && (
<>
<CommandSeparator className="mb-1.5" />
<CommandGroup heading={t`Admin`}>
<CommandItem
keywords={["pocketbase"]}
onSelect={() => {
setOpen(false)
window.open("/_/", "_blank")
}}
>
<UsersIcon className="me-2 h-4 w-4" />
<span>
<Trans>Users</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/logs", "_blank")
}}
>
<LogsIcon className="me-2 h-4 w-4" />
<span>
<Trans>Logs</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/backups", "_blank")
}}
>
<DatabaseBackupIcon className="me-2 h-4 w-4" />
<span>
<Trans>Backups</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
<CommandItem
keywords={["email"]}
onSelect={() => {
setOpen(false)
window.open("/_/#/settings/mail", "_blank")
}}
>
<MailIcon className="me-2 h-4 w-4" />
<span>
<Trans>SMTP settings</Trans>
</span>
<CommandShortcut>
<Trans>Admin</Trans>
</CommandShortcut>
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</CommandDialog>
)
}

View File

@@ -1,8 +1,8 @@
import { Trans } from "@lingui/react/macro";
import { useEffect, useMemo, useRef } from "react" import { useEffect, useMemo, useRef } from "react"
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog" import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "./ui/dialog"
import { Textarea } from "./ui/textarea" import { Textarea } from "./ui/textarea"
import { $copyContent } from "@/lib/stores" import { $copyContent } from "@/lib/stores"
import { Trans } from "@lingui/macro"
export default function CopyToClipboard({ content }: { content: string }) { export default function CopyToClipboard({ content }: { content: string }) {
return ( return (

View File

@@ -4,7 +4,7 @@ import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import languages from "@/lib/languages" import languages from "@/lib/languages"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react/macro"
import { dynamicActivate } from "@/lib/i18n" import { dynamicActivate } from "@/lib/i18n"
export function LangToggle() { export function LangToggle() {

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -10,7 +12,6 @@ import { Dialog, DialogContent, DialogTrigger, DialogHeader, DialogTitle } from
import { useCallback, useEffect, useState } from "react" import { useCallback, useEffect, useState } from "react"
import { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase" import { AuthMethodsList, AuthProviderInfo, OAuth2AuthConfig } from "pocketbase"
import { $router, Link, prependBasePath } from "../router" import { $router, Link, prependBasePath } from "../router"
import { Trans, t } from "@lingui/macro"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
const honeypot = v.literal("") const honeypot = v.literal("")
@@ -135,7 +136,6 @@ export function UserAuthForm({
toast({ toast({
title: t`Error`, title: t`Error`,
description: t`Please enable pop-ups for this site`, description: t`Please enable pop-ups for this site`,
variant: "destructive",
}) })
return return
} }
@@ -156,14 +156,17 @@ export function UserAuthForm({
useEffect(() => { useEffect(() => {
// auto login if password disabled and only one auth provider // auto login if password disabled and only one auth provider
if (!passwordEnabled && authProviders.length === 1) { if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
loginWithOauth(authProviders[0], true) // Add a small timeout to ensure browser is ready to handle popups
setTimeout(() => {
loginWithOauth(authProviders[0], true)
}, 300)
} }
}, []) }, [])
return ( return (
<div className={cn("grid gap-6", className)} {...props}> <div className={cn("grid gap-6", className)} {...props}>
{passwordEnabled && ( {passwordEnabled && (
<> <>
<form onSubmit={handleSubmit} onChange={() => setErrors({})}> <form onSubmit={handleSubmit} onChange={() => setErrors({})}>
<div className="grid gap-2.5"> <div className="grid gap-2.5">
@@ -239,21 +242,20 @@ export function UserAuthForm({
</form> </form>
{(isFirstRun || oauthEnabled) && ( {(isFirstRun || oauthEnabled) && (
// only show 'continue with' during onboarding or if we have auth providers // only show 'continue with' during onboarding or if we have auth providers
<div className="relative"> (<div className="relative">
<div className="absolute inset-0 flex items-center"> <div className="absolute inset-0 flex items-center">
<span className="w-full border-t" /> <span className="w-full border-t" />
</div> </div>
<div className="relative flex justify-center text-xs uppercase"> <div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground"> <span className="bg-background px-2 text-muted-foreground">
<Trans>Or continue with</Trans> <Trans>Or continue with</Trans>
</span> </span>
</div> </div>
</div> </div>)
)} )}
</> </>
)} )}
{oauthEnabled && (
{oauthEnabled && (
<div className="grid gap-2 -mt-1"> <div className="grid gap-2 -mt-1">
{authMethods.oauth2.providers.map((provider) => ( {authMethods.oauth2.providers.map((provider) => (
<button <button
@@ -283,17 +285,16 @@ export function UserAuthForm({
))} ))}
</div> </div>
)} )}
{!oauthEnabled && isFirstRun && (
{!oauthEnabled && isFirstRun && (
// only show GitHub button / dialog during onboarding // only show GitHub button / dialog during onboarding
<Dialog> (<Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
<button type="button" className={cn(buttonVariants({ variant: "outline" }))}> <button type="button" className={cn(buttonVariants({ variant: "outline" }))}>
<img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" /> <img className="me-2 h-4 w-4 dark:invert" src={prependBasePath("/_/images/oauth2/github.svg")} alt="" />
<span className="translate-y-[1px]">GitHub</span> <span className="translate-y-[1px]">GitHub</span>
</button> </button>
</DialogTrigger> </DialogTrigger>
<DialogContent style={{ maxWidth: 440, width: "90%" }}> <DialogContent style={{ maxWidth: 440, width: "90%" }}>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
<Trans>OAuth 2 / OIDC support</Trans> <Trans>OAuth 2 / OIDC support</Trans>
@@ -317,10 +318,9 @@ export function UserAuthForm({
</p> </p>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>)
)} )}
{passwordEnabled && !isFirstRun && (
{passwordEnabled && !isFirstRun && (
<Link <Link
href={getPagePath($router, "forgot_password")} href={getPagePath($router, "forgot_password")}
className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity" className="text-sm mx-auto hover:text-brand underline underline-offset-4 opacity-70 hover:opacity-100 transition-opacity"
@@ -328,6 +328,6 @@ export function UserAuthForm({
<Trans>Forgot password?</Trans> <Trans>Forgot password?</Trans>
</Link> </Link>
)} )}
</div> </div>
) );
} }

View File

@@ -1,3 +1,5 @@
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react" import { LoaderCircle, MailIcon, SendHorizonalIcon } from "lucide-react"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { Label } from "../ui/label" import { Label } from "../ui/label"
@@ -8,7 +10,6 @@ import { cn } from "@/lib/utils"
import { pb } from "@/lib/stores" import { pb } from "@/lib/stores"
import { Dialog, DialogHeader } from "../ui/dialog" import { Dialog, DialogHeader } from "../ui/dialog"
import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog" import { DialogContent, DialogTrigger, DialogTitle } from "../ui/dialog"
import { t, Trans } from "@lingui/macro"
const showLoginFaliedToast = () => { const showLoginFaliedToast = () => {
toast({ toast({

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { UserAuthForm } from "@/components/login/auth-form" import { UserAuthForm } from "@/components/login/auth-form"
import { Logo } from "../logo" import { Logo } from "../logo"
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
@@ -6,7 +7,6 @@ import { useStore } from "@nanostores/react"
import ForgotPassword from "./forgot-pass-form" import ForgotPassword from "./forgot-pass-form"
import { $router } from "../router" import { $router } from "../router"
import { AuthMethodsList } from "pocketbase" import { AuthMethodsList } from "pocketbase"
import { t } from "@lingui/macro"
import { useTheme } from "../theme-provider" import { useTheme } from "../theme-provider"
export default function () { export default function () {

View File

@@ -1,10 +1,11 @@
import { Trans } from "@lingui/react/macro";
import { t } from "@lingui/core/macro";
import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react" import { LaptopIcon, MoonStarIcon, SunIcon } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { useTheme } from "@/components/theme-provider" import { useTheme } from "@/components/theme-provider"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
import { t, Trans } from "@lingui/macro"
export function ModeToggle() { export function ModeToggle() {
const { theme, setTheme } = useTheme() const { theme, setTheme } = useTheme()

View File

@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro";
import { useState, lazy, Suspense } from "react" import { useState, lazy, Suspense } from "react"
import { Button, buttonVariants } from "@/components/ui/button" import { Button, buttonVariants } from "@/components/ui/button"
import { import {
@@ -26,7 +27,6 @@ import {
DropdownMenuItem, DropdownMenuItem,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { AddSystemButton } from "./add-system" import { AddSystemButton } from "./add-system"
import { Trans } from "@lingui/macro"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
const CommandPalette = lazy(() => import("./command-palette")) const CommandPalette = lazy(() => import("./command-palette"))

View File

@@ -11,7 +11,7 @@ const routes = {
* The base path of the application. * The base path of the application.
* This is used to prepend the base path to all routes. * This is used to prepend the base path to all routes.
*/ */
export const basePath = window.BASE_PATH || "" export const basePath = globalThis.BESZEL.BASE_PATH || ""
/** /**
* Prepends the base path to the given path. * Prepends the base path to the given path.

View File

@@ -1,6 +1,6 @@
import { Suspense, lazy, useEffect, useMemo } from "react" import { Suspense, lazy, memo, useEffect, useMemo } from "react"
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
import { $alerts, $hubVersion, $systems, pb } from "@/lib/stores" import { $alerts, $systems, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react" import { GithubIcon } from "lucide-react"
import { Separator } from "../ui/separator" import { Separator } from "../ui/separator"
@@ -8,17 +8,17 @@ import { alertInfo, updateRecordList, updateSystemList } from "@/lib/utils"
import { AlertRecord, SystemRecord } from "@/types" import { AlertRecord, SystemRecord } from "@/types"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { $router, Link } from "../router" import { $router, Link } from "../router"
import { Plural, t, Trans } from "@lingui/macro" import { Plural, Trans, useLingui } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router" import { getPagePath } from "@nanostores/router"
const SystemsTable = lazy(() => import("../systems-table/systems-table")) const SystemsTable = lazy(() => import("../systems-table/systems-table"))
export default function Home() { export const Home = memo(() => {
const hubVersion = useStore($hubVersion)
const alerts = useStore($alerts) const alerts = useStore($alerts)
const systems = useStore($systems) const systems = useStore($systems)
const { t } = useLingui()
let alertsKey = ""
const activeAlerts = useMemo(() => { const activeAlerts = useMemo(() => {
const activeAlerts = alerts.filter((alert) => { const activeAlerts = alerts.filter((alert) => {
const active = alert.triggered && alert.name in alertInfo const active = alert.triggered && alert.name in alertInfo
@@ -26,14 +26,17 @@ export default function Home() {
return false return false
} }
alert.sysname = systems.find((system) => system.id === alert.system)?.name alert.sysname = systems.find((system) => system.id === alert.system)?.name
alertsKey += alert.id
return true return true
}) })
return activeAlerts return activeAlerts
}, [alerts]) }, [systems, alerts])
useEffect(() => { useEffect(() => {
document.title = t`Dashboard` + " / Beszel" document.title = t`Dashboard` + " / Beszel"
}, [t])
useEffect(() => {
// make sure we have the latest list of systems // make sure we have the latest list of systems
updateSystemList() updateSystemList()
@@ -41,7 +44,6 @@ export default function Home() {
pb.collection<SystemRecord>("systems").subscribe("*", (e) => { pb.collection<SystemRecord>("systems").subscribe("*", (e) => {
updateRecordList(e, $systems) updateRecordList(e, $systems)
}) })
// todo: add toast if new triggered alert comes in
pb.collection<AlertRecord>("alerts").subscribe("*", (e) => { pb.collection<AlertRecord>("alerts").subscribe("*", (e) => {
updateRecordList(e, $alerts) updateRecordList(e, $alerts)
}) })
@@ -51,56 +53,15 @@ export default function Home() {
} }
}, []) }, [])
return ( return useMemo(
<> () => (
{/* show active alerts */} <>
{activeAlerts.length > 0 && ( {/* show active alerts */}
<Card className="mb-4"> {activeAlerts.length > 0 && <ActiveAlerts key={activeAlerts.length} activeAlerts={activeAlerts} />}
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <Suspense>
<div className="px-2 sm:px-1"> <SystemsTable />
<CardTitle> </Suspense>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: alert.sysname! })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)}
<Suspense>
<SystemsTable />
</Suspense>
{hubVersion && (
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80"> <div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 text-xs opacity-80">
<a <a
href="https://github.com/henrygd/beszel" href="https://github.com/henrygd/beszel"
@@ -115,10 +76,56 @@ export default function Home() {
target="_blank" target="_blank"
className="text-muted-foreground hover:text-foreground duration-75" className="text-muted-foreground hover:text-foreground duration-75"
> >
Beszel {hubVersion} Beszel {globalThis.BESZEL.HUB_VERSION}
</a> </a>
</div> </div>
)} </>
</> ),
[alertsKey]
) )
} })
const ActiveAlerts = memo(({ activeAlerts }: { activeAlerts: AlertRecord[] }) => {
return (
<Card className="mb-4">
<CardHeader className="pb-4 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="px-2 sm:px-1">
<CardTitle>
<Trans>Active Alerts</Trans>
</CardTitle>
</div>
</CardHeader>
<CardContent className="max-sm:p-2">
{activeAlerts.length > 0 && (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3">
{activeAlerts.map((alert) => {
const info = alertInfo[alert.name as keyof typeof alertInfo]
return (
<Alert
key={alert.id}
className="hover:-translate-y-[1px] duration-200 bg-transparent border-foreground/10 hover:shadow-md shadow-black"
>
<info.icon className="h-4 w-4" />
<AlertTitle>
{alert.sysname} {info.name().toLowerCase().replace("cpu", "CPU")}
</AlertTitle>
<AlertDescription>
<Trans>
Exceeds {alert.value}
{info.unit} in last <Plural value={alert.min} one="# minute" other="# minutes" />
</Trans>
</AlertDescription>
<Link
href={getPagePath($router, "system", { name: alert.sysname! })}
className="absolute inset-0 w-full h-full"
aria-label="View system"
></Link>
</Alert>
)
})}
</div>
)}
</CardContent>
</Card>
)
})

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { isAdmin } from "@/lib/utils" import { isAdmin } from "@/lib/utils"
import { Separator } from "@/components/ui/separator" import { Separator } from "@/components/ui/separator"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -10,7 +12,6 @@ import { useState } from "react"
import { Textarea } from "@/components/ui/textarea" import { Textarea } from "@/components/ui/textarea"
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import clsx from "clsx" import clsx from "clsx"
import { Trans, t } from "@lingui/macro"
export default function ConfigYaml() { export default function ConfigYaml() {
const [configContent, setConfigContent] = useState<string>("") const [configContent, setConfigContent] = useState<string>("")

View File

@@ -1,3 +1,4 @@
import { Trans } from "@lingui/react/macro"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -7,10 +8,9 @@ import { LanguagesIcon, LoaderCircleIcon, SaveIcon } from "lucide-react"
import { UserSettings } from "@/types" import { UserSettings } from "@/types"
import { saveSettings } from "./layout" import { saveSettings } from "./layout"
import { useState } from "react" import { useState } from "react"
import { Trans } from "@lingui/macro"
import languages from "@/lib/languages" import languages from "@/lib/languages"
import { dynamicActivate } from "@/lib/i18n" import { dynamicActivate } from "@/lib/i18n"
import { useLingui } from "@lingui/react" import { useLingui } from "@lingui/react/macro"
// import { setLang } from "@/lib/i18n" // import { setLang } from "@/lib/i18n"
export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) { export default function SettingsProfilePage({ userSettings }: { userSettings: UserSettings }) {

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { useEffect } from "react" import { useEffect } from "react"
import { Separator } from "../../ui/separator" import { Separator } from "../../ui/separator"
import { SidebarNav } from "./sidebar-nav.tsx" import { SidebarNav } from "./sidebar-nav.tsx"
@@ -12,8 +14,7 @@ import { UserSettings } from "@/types.js"
import General from "./general.tsx" import General from "./general.tsx"
import Notifications from "./notifications.tsx" import Notifications from "./notifications.tsx"
import ConfigYaml from "./config-yaml.tsx" import ConfigYaml from "./config-yaml.tsx"
import { Trans, t } from "@lingui/macro" import { useLingui } from "@lingui/react/macro"
import { useLingui } from "@lingui/react"
export async function saveSettings(newSettings: Partial<UserSettings>) { export async function saveSettings(newSettings: Partial<UserSettings>) {
try { try {
@@ -44,11 +45,11 @@ export async function saveSettings(newSettings: Partial<UserSettings>) {
} }
export default function SettingsLayout() { export default function SettingsLayout() {
const { _ } = useLingui() const { t } = useLingui()
const sidebarNavItems = [ const sidebarNavItems = [
{ {
title: _(t({ message: `General`, comment: "Context: General settings" })), title: t({ message: `General`, comment: "Context: General settings" }),
href: getPagePath($router, "settings", { name: "general" }), href: getPagePath($router, "settings", { name: "general" }),
icon: SettingsIcon, icon: SettingsIcon,
}, },

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label"
@@ -12,7 +14,6 @@ import { UserSettings } from "@/types"
import { saveSettings } from "./layout" import { saveSettings } from "./layout"
import * as v from "valibot" import * as v from "valibot"
import { isAdmin } from "@/lib/utils" import { isAdmin } from "@/lib/utils"
import { Trans, t } from "@lingui/macro"
import { prependBasePath } from "@/components/router" import { prependBasePath } from "@/components/router"
interface ShoutrrrUrlCardProps { interface ShoutrrrUrlCardProps {

View File

@@ -1,3 +1,5 @@
import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro"
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction, $maxValues } from "@/lib/stores" import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction, $maxValues } from "@/lib/stores"
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types" import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react" import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
@@ -12,6 +14,7 @@ import {
getHostDisplayValue, getHostDisplayValue,
getPbTimestamp, getPbTimestamp,
getSizeAndUnit, getSizeAndUnit,
listen,
toFixedFloat, toFixedFloat,
useLocalStorage, useLocalStorage,
} from "@/lib/utils" } from "@/lib/utils"
@@ -23,8 +26,9 @@ import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
import { useIntersectionObserver } from "@/lib/use-intersection-observer" import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"
import { timeTicks } from "d3-time" import { timeTicks } from "d3-time"
import { Plural, Trans, t } from "@lingui/macro" import { useLingui } from "@lingui/react/macro"
import { useLingui } from "@lingui/react" import { $router, navigate } from "../router"
import { getPagePath } from "@nanostores/router"
const AreaChartDefault = lazy(() => import("../charts/area-chart")) const AreaChartDefault = lazy(() => import("../charts/area-chart"))
const ContainerChart = lazy(() => import("../charts/container-chart")) const ContainerChart = lazy(() => import("../charts/container-chart"))
@@ -103,7 +107,7 @@ function dockerOrPodman(str: string, system: SystemRecord) {
export default function SystemDetail({ name }: { name: string }) { export default function SystemDetail({ name }: { name: string }) {
const direction = useStore($direction) const direction = useStore($direction)
const { _ } = useLingui() const { t } = useLingui()
const systems = useStore($systems) const systems = useStore($systems)
const chartTime = useStore($chartTime) const chartTime = useStore($chartTime)
const maxValues = useStore($maxValues) const maxValues = useStore($maxValues)
@@ -112,6 +116,7 @@ export default function SystemDetail({ name }: { name: string }) {
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[]) const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"]) const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const netCardRef = useRef<HTMLDivElement>(null) const netCardRef = useRef<HTMLDivElement>(null)
const persistChartTime = useRef(false)
const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element) const [containerFilterBar, setContainerFilterBar] = useState(null as null | JSX.Element)
const [bottomSpacing, setBottomSpacing] = useState(0) const [bottomSpacing, setBottomSpacing] = useState(0)
const [chartLoading, setChartLoading] = useState(true) const [chartLoading, setChartLoading] = useState(true)
@@ -120,8 +125,10 @@ export default function SystemDetail({ name }: { name: string }) {
useEffect(() => { useEffect(() => {
document.title = `${name} / Beszel` document.title = `${name} / Beszel`
return () => { return () => {
$chartTime.set($userSettings.get().chartTime) if (!persistChartTime.current) {
// resetCharts() $chartTime.set($userSettings.get().chartTime)
}
persistChartTime.current = false
setSystemStats([]) setSystemStats([])
setContainerData([]) setContainerData([])
setContainerFilterBar(null) setContainerFilterBar(null)
@@ -260,7 +267,7 @@ export default function SystemDetail({ name }: { name: string }) {
// hide if hostname is same as host or name // hide if hostname is same as host or name
hide: system.info.h === system.host || system.info.h === system.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.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` : ""})`, value: `${system.info.m} (${system.info.c}c${system.info.t ? `/${system.info.t}t` : ""})`,
@@ -289,6 +296,35 @@ export default function SystemDetail({ name }: { name: string }) {
setBottomSpacing(tooltipHeight - distanceToBottom) setBottomSpacing(tooltipHeight - distanceToBottom)
}, [netCardRef, containerData]) }, [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) { if (!system.id) {
return null return null
} }
@@ -395,7 +431,7 @@ export default function SystemDetail({ name }: { name: string }) {
<ChartCard <ChartCard
empty={dataEmpty} empty={dataEmpty}
grid={grid} grid={grid}
title={_(t`CPU Usage`)} title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`} description={t`Average system-wide CPU utilization`}
cornerEl={maxValSelect} cornerEl={maxValSelect}
> >
@@ -520,6 +556,10 @@ export default function SystemDetail({ name }: { name: string }) {
<div className="grid xl:grid-cols-2 gap-4"> <div className="grid xl:grid-cols-2 gap-4">
{Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => { {Object.keys(systemStats.at(-1)?.stats.g ?? {}).map((id) => {
const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData const gpu = systemStats.at(-1)?.stats.g?.[id] as GPUData
const sizeFormatter = (value: number, decimals?: number) => {
const { v, u } = getSizeAndUnit(value, false)
return toFixedFloat(v, decimals || 1) + u
}
return ( return (
<div key={id} className="contents"> <div key={id} className="contents">
<ChartCard <ChartCard
@@ -539,12 +579,9 @@ export default function SystemDetail({ name }: { name: string }) {
<AreaChartDefault <AreaChartDefault
chartData={chartData} chartData={chartData}
chartName={`g.${id}.mu`} chartName={`g.${id}.mu`}
unit=" MB"
max={gpu.mt} max={gpu.mt}
tickFormatter={(value) => { tickFormatter={sizeFormatter}
const { v, u } = getSizeAndUnit(value, false) contentFormatter={(value) => sizeFormatter(value, 2)}
return toFixedFloat(v, 1) + u
}}
/> />
</ChartCard> </ChartCard>
</div> </div>
@@ -595,7 +632,7 @@ export default function SystemDetail({ name }: { name: string }) {
function ContainerFilterBar() { function ContainerFilterBar() {
const containerFilter = useStore($containerFilter) const containerFilter = useStore($containerFilter)
const { _ } = useLingui() const { t } = useLingui()
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
$containerFilter.set(e.target.value) $containerFilter.set(e.target.value)
@@ -603,7 +640,7 @@ function ContainerFilterBar() {
return ( return (
<> <>
<Input placeholder={_(t`Filter...`)} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} /> <Input placeholder={t`Filter...`} className="ps-4 pe-8" value={containerFilter} onChange={handleChange} />
{containerFilter && ( {containerFilter && (
<Button <Button
type="button" type="button"

View File

@@ -10,6 +10,8 @@ import {
getCoreRowModel, getCoreRowModel,
useReactTable, useReactTable,
HeaderContext, HeaderContext,
Row,
Table as TableType,
} from "@tanstack/react-table" } from "@tanstack/react-table"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table" import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -61,14 +63,13 @@ import {
PenBoxIcon, PenBoxIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useEffect, useMemo, useRef, useState } from "react" import { memo, useEffect, useMemo, useRef, useState } from "react"
import { $hubVersion, $systems, pb } from "@/lib/stores" import { $systems, pb } from "@/lib/stores"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils" import { cn, copyToClipboard, decimalString, isReadOnlyUser, useLocalStorage } from "@/lib/utils"
import AlertsButton from "../alerts/alert-button" import AlertsButton from "../alerts/alert-button"
import { $router, Link, navigate } from "../router" import { $router, Link, navigate } from "../router"
import { EthernetIcon, GpuIcon, ThermometerIcon } from "../ui/icons" import { EthernetIcon, GpuIcon, ThermometerIcon } from "../ui/icons"
import { Trans, t } from "@lingui/macro" import { useLingui, Trans } from "@lingui/react/macro"
import { useLingui } from "@lingui/react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { Input } from "../ui/input" import { Input } from "../ui/input"
import { ClassValue } from "clsx" import { ClassValue } from "clsx"
@@ -103,47 +104,66 @@ function CellFormatter(info: CellContext<SystemRecord, unknown>) {
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) { function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
const { column } = context const { column } = context
// @ts-ignore
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className="h-9 px-3 flex" className="h-9 px-3 flex"
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
{/* @ts-ignore */} {Icon && <Icon className="me-2 size-4" />}
{column.columnDef.icon && <column.columnDef.icon className="me-2 size-4" />} {name()}
{column.id} {hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
{/* @ts-ignore */}
{column.columnDef.hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
</Button> </Button>
) )
} }
export default function SystemsTable() { export default function SystemsTable() {
const data = useStore($systems) const data = useStore($systems)
const hubVersion = useStore($hubVersion) const { i18n, t } = useLingui()
const [filter, setFilter] = useState<string>() const [filter, setFilter] = useState<string>()
const [sorting, setSorting] = useState<SortingState>([{ id: t`System`, desc: false }]) const [sorting, setSorting] = useState<SortingState>([{ id: "system", desc: false }])
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {}) const [columnVisibility, setColumnVisibility] = useLocalStorage<VisibilityState>("cols", {})
const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid") const [viewMode, setViewMode] = useLocalStorage<ViewMode>("viewMode", window.innerWidth > 1024 ? "table" : "grid")
const { i18n } = useLingui()
const locale = i18n.locale
useEffect(() => { useEffect(() => {
if (filter !== undefined) { if (filter !== undefined) {
table.getColumn(t`System`)?.setFilterValue(filter) table.getColumn("system")?.setFilterValue(filter)
} }
}, [filter]) }, [filter])
const columns = useMemo(() => { const columnDefs = useMemo(() => {
const statusTranslations = {
up: () => t`Up`.toLowerCase(),
down: () => t`Down`.toLowerCase(),
paused: () => t`Paused`.toLowerCase(),
}
return [ return [
{ {
// size: 200, // size: 200,
size: 200, size: 200,
minSize: 0, minSize: 0,
accessorKey: "name", accessorKey: "name",
id: t`System`, id: "system",
name: () => 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, enableHiding: false,
icon: ServerIcon, Icon: ServerIcon,
cell: (info) => ( cell: (info) => (
<span className="flex gap-0.5 items-center text-base md:pe-5"> <span className="flex gap-0.5 items-center text-base md:pe-5">
<IndicatorDot system={info.row.original} /> <IndicatorDot system={info.row.original} />
@@ -162,43 +182,48 @@ export default function SystemsTable() {
}, },
{ {
accessorKey: "info.cpu", accessorKey: "info.cpu",
id: t`CPU`, id: "cpu",
name: () => t`CPU`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
icon: CpuIcon, Icon: CpuIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorKey: "info.mp", accessorKey: "info.mp",
id: t`Memory`, id: "memory",
name: () => t`Memory`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
icon: MemoryStickIcon, Icon: MemoryStickIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorKey: "info.dp", accessorKey: "info.dp",
id: t`Disk`, id: "disk",
name: () => t`Disk`,
invertSorting: true, invertSorting: true,
cell: CellFormatter, cell: CellFormatter,
icon: HardDriveIcon, Icon: HardDriveIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: (originalRow) => originalRow.info.g, accessorFn: (originalRow) => originalRow.info.g,
id: "GPU", id: "gpu",
name: () => "GPU",
invertSorting: true, invertSorting: true,
sortUndefined: -1, sortUndefined: -1,
cell: CellFormatter, cell: CellFormatter,
icon: GpuIcon, Icon: GpuIcon,
header: sortableHeader, header: sortableHeader,
}, },
{ {
accessorFn: (originalRow) => originalRow.info.b || 0, accessorFn: (originalRow) => originalRow.info.b || 0,
id: t`Net`, id: "net",
name: () => t`Net`,
invertSorting: true, invertSorting: true,
size: 50, size: 50,
icon: EthernetIcon, Icon: EthernetIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const val = info.getValue() as number const val = info.getValue() as number
@@ -215,15 +240,13 @@ export default function SystemsTable() {
}, },
{ {
accessorFn: (originalRow) => originalRow.info.dt, accessorFn: (originalRow) => originalRow.info.dt,
id: t({ id: "temp",
message: "Temp", name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
comment: "Temperature label in systems table",
}),
invertSorting: true, invertSorting: true,
sortUndefined: -1, sortUndefined: -1,
size: 50, size: 50,
hideSort: true, hideSort: true,
icon: ThermometerIcon, Icon: ThermometerIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const val = info.getValue() as number const val = info.getValue() as number
@@ -243,15 +266,16 @@ export default function SystemsTable() {
}, },
{ {
accessorKey: "info.v", accessorKey: "info.v",
id: t`Agent`, id: "agent",
name: () => t`Agent`,
invertSorting: true, invertSorting: true,
size: 50, size: 50,
icon: WifiIcon, Icon: WifiIcon,
hideSort: true, hideSort: true,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const version = info.getValue() as string const version = info.getValue() as string
if (!version || !hubVersion) { if (!version) {
return null return null
} }
const system = info.row.original const system = info.row.original
@@ -265,7 +289,7 @@ export default function SystemsTable() {
system={system} system={system}
className={ className={
(system.status !== "up" && "bg-primary/30") || (system.status !== "up" && "bg-primary/30") ||
(version === hubVersion && "bg-green-500") || (version === globalThis.BESZEL.HUB_VERSION && "bg-green-500") ||
"bg-yellow-500" "bg-yellow-500"
} }
/> />
@@ -275,7 +299,9 @@ export default function SystemsTable() {
}, },
}, },
{ {
id: t({ message: "Actions", comment: "Table column" }), id: "actions",
// @ts-ignore
name: () => t({ message: "Actions", comment: "Table column" }),
size: 50, size: 50,
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex justify-end items-center gap-1"> <div className="flex justify-end items-center gap-1">
@@ -285,11 +311,11 @@ export default function SystemsTable() {
), ),
}, },
] as ColumnDef<SystemRecord>[] ] as ColumnDef<SystemRecord>[]
}, [hubVersion, i18n.locale]) }, [])
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns: columnDefs,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
onSortingChange: setSorting, onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
@@ -303,15 +329,17 @@ export default function SystemsTable() {
}, },
defaultColumn: { defaultColumn: {
minSize: 0, minSize: 0,
size: Number.MAX_SAFE_INTEGER, size: 900,
maxSize: Number.MAX_SAFE_INTEGER, maxSize: 900,
}, },
}) })
const rows = table.getRowModel().rows const rows = table.getRowModel().rows
const columns = table.getAllColumns()
return ( const visibleColumns = table.getVisibleLeafColumns()
<Card> // TODO: hiding temp then gpu messes up table headers
const CardHead = useMemo(() => {
return (
<CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1"> <CardHeader className="pb-5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-5 w-full items-end"> <div className="grid md:flex gap-5 w-full items-end">
<div className="px-2 sm:px-1"> <div className="px-2 sm:px-1">
@@ -362,8 +390,8 @@ export default function SystemsTable() {
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="px-1 pb-1"> <div className="px-1 pb-1">
{table.getAllColumns().map((column) => { {columns.map((column) => {
if (column.id === t`Actions` || !column.getCanSort()) return null if (!column.getCanSort()) return null
let Icon = <span className="w-6"></span> let Icon = <span className="w-6"></span>
// if current sort column, show sort direction // if current sort column, show sort direction
if (sorting[0]?.id === column.id) { if (sorting[0]?.id === column.id) {
@@ -382,7 +410,8 @@ export default function SystemsTable() {
key={column.id} key={column.id}
> >
{Icon} {Icon}
{column.id} {/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuItem> </DropdownMenuItem>
) )
})} })}
@@ -396,8 +425,7 @@ export default function SystemsTable() {
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<div className="px-1.5 pb-1"> <div className="px-1.5 pb-1">
{table {columns
.getAllColumns()
.filter((column) => column.getCanHide()) .filter((column) => column.getCanHide())
.map((column) => { .map((column) => {
return ( return (
@@ -407,7 +435,8 @@ export default function SystemsTable() {
checked={column.getIsVisible()} checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)} onCheckedChange={(value) => column.toggleVisibility(!!value)}
> >
{column.id} {/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
) )
})} })}
@@ -419,128 +448,24 @@ export default function SystemsTable() {
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
)
}, [visibleColumns.length, sorting, viewMode, locale])
return (
<Card>
{CardHead}
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2"> <div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
{viewMode === "table" ? ( {viewMode === "table" ? (
// table layout // table layout
<div className="rounded-md border overflow-hidden"> <div className="rounded-md border overflow-hidden">
<Table> <AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.original.id}
data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity", {
"opacity-50": row.original.status === "paused",
})}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(getPagePath($router, "system", { name: row.original.name }))
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize() === Number.MAX_SAFE_INTEGER ? "auto" : cell.column.getSize(),
}}
className={cn("overflow-hidden relative", data.length > 10 ? "py-2" : "py-2.5")}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
<Trans>No systems found.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div> </div>
) : ( ) : (
// grid layout // grid layout
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{table.getRowModel().rows?.length ? ( {rows?.length ? (
table.getRowModel().rows.map((row) => { rows.map((row) => {
const system = row.original return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.length} />
const { status } = system
return (
<Card
key={system.id}
className={cn(
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
{
"opacity-50": status === "paused",
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
<div className="flex items-center gap-2.5 min-w-0">
<IndicatorDot system={system} />
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
</div>
</CardTitle>
{table.getColumn(t`Actions`)?.getIsVisible() && (
<div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertsButton system={system} />
<ActionsButton system={system} />
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
{table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === t`System` || column.id === t`Actions`) return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
if (!cell) return null
return (
<div key={column.id} className="flex items-center gap-3">
{/* @ts-ignore */}
{column.columnDef?.icon && (
// @ts-ignore
<column.columnDef.icon className="size-4 text-muted-foreground" />
)}
<div className="flex items-center gap-3 flex-1">
<span className="text-muted-foreground min-w-16">{column.id}:</span>
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
</div>
</div>
)
})}
</CardContent>
<Link
href={getPagePath($router, "system", { name: row.original.name })}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>
</Link>
</Card>
)
}) })
) : ( ) : (
<div className="col-span-full text-center py-8"> <div className="col-span-full text-center py-8">
@@ -554,6 +479,247 @@ export default function SystemsTable() {
) )
} }
const AllSystemsTable = memo(
({ table, rows, colLength }: { table: TableType<SystemRecord>; rows: Row<SystemRecord>[]; colLength: number }) => {
return (
<Table>
<SystemsTableHead table={table} colLength={colLength} />
<TableBody>
{rows.length ? (
rows.map((row) => (
<SystemTableRow key={row.original.id} row={row} length={rows.length} colLength={colLength} />
))
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-24 text-center">
<Trans>No systems found.</Trans>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)
}
)
function SystemsTableHead({ table, colLength }: { table: TableType<SystemRecord>; colLength: number }) {
const { i18n } = useLingui()
return useMemo(() => {
return (
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead className="px-2" key={header.id}>
{flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
)
}, [i18n.locale, colLength])
}
const SystemTableRow = memo(
({ row, length, colLength }: { row: Row<SystemRecord>; length: number; colLength: number }) => {
const system = row.original
const { t } = useLingui()
return useMemo(() => {
return (
<TableRow
// data-state={row.getIsSelected() && "selected"}
className={cn("cursor-pointer transition-opacity", {
"opacity-50": system.status === "paused",
})}
onClick={(e) => {
const target = e.target as HTMLElement
if (!target.closest("[data-nolink]") && e.currentTarget.contains(target)) {
navigate(getPagePath($router, "system", { name: system.name }))
}
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={{
width: cell.column.getSize(),
}}
className={cn("overflow-hidden relative", length > 10 ? "py-2" : "py-2.5")}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}, [system, system.status, colLength, t])
}
)
const SystemCard = memo(
({ row, table, colLength }: { row: Row<SystemRecord>; table: TableType<SystemRecord>; colLength: number }) => {
const system = row.original
const { t } = useLingui()
return useMemo(() => {
return (
<Card
key={system.id}
className={cn(
"cursor-pointer hover:shadow-md transition-all bg-transparent w-full dark:border-border duration-200 relative",
{
"opacity-50": system.status === "paused",
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base tracking-normal shrink-1 text-primary/90 flex items-center min-h-10 gap-2.5 min-w-0">
<div className="flex items-center gap-2.5 min-w-0">
<IndicatorDot system={system} />
<CardTitle className="text-[.95em]/normal tracking-normal truncate text-primary/90">
{system.name}
</CardTitle>
</div>
</CardTitle>
{table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 flex-shrink-0 relative z-10">
<AlertsButton system={system} />
<ActionsButton system={system} />
</div>
)}
</div>
</CardHeader>
<CardContent className="space-y-2.5 text-sm px-5 pt-3.5 pb-4">
{table.getAllColumns().map((column) => {
if (!column.getIsVisible() || column.id === "system" || column.id === "actions") return null
const cell = row.getAllCells().find((cell) => cell.column.id === column.id)
if (!cell) return null
// @ts-ignore
const { Icon, name } = column.columnDef as ColumnDef<SystemRecord, unknown>
return (
<div key={column.id} className="flex items-center gap-3">
{Icon && <Icon className="size-4 text-muted-foreground" />}
<div className="flex items-center gap-3 flex-1">
<span className="text-muted-foreground min-w-16">{name()}:</span>
<div className="flex-1">{flexRender(cell.column.columnDef.cell, cell.getContext())}</div>
</div>
</div>
)
})}
</CardContent>
<Link
href={getPagePath($router, "system", { name: row.original.name })}
className="inset-0 absolute w-full h-full"
>
<span className="sr-only">{row.original.name}</span>
</Link>
</Card>
)
}, [system, colLength, t])
}
)
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
let editOpened = useRef(false)
const { t } = useLingui()
const { id, status, host, name } = system
return useMemo(() => {
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isReadOnlyUser() && (
<DropdownMenuItem
onSelect={() => {
editOpened.current = true
setEditOpen(true)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")}
onClick={() => {
pb.collection("systems").update(id, {
status: status === "paused" ? "pending" : "paused",
})
}}
>
{status === "paused" ? (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy host</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
</Dialog>
{/* deletion dialog */}
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This action cannot be undone. This will permanently delete all current records for {name} from the
database.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}, [id, status, host, name, t, deleteOpen, editOpen])
})
function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) { function IndicatorDot({ system, className }: { system: SystemRecord; className?: ClassValue }) {
className ||= { className ||= {
"bg-green-500": system.status === "up", "bg-green-500": system.status === "up",
@@ -568,99 +734,3 @@ function IndicatorDot({ system, className }: { system: SystemRecord; className?:
/> />
) )
} }
const ActionsButton = memo(({ system }: { system: SystemRecord }) => {
const [deleteOpen, setDeleteOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
let editOpened = useRef(false)
const { id, status, host, name } = system
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size={"icon"} data-nolink>
<span className="sr-only">
<Trans>Open menu</Trans>
</span>
<MoreHorizontalIcon className="w-5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isReadOnlyUser() && (
<DropdownMenuItem
onSelect={() => {
editOpened.current = true
setEditOpen(true)
}}
>
<PenBoxIcon className="me-2.5 size-4" />
<Trans>Edit</Trans>
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(isReadOnlyUser() && "hidden")}
onClick={() => {
pb.collection("systems").update(id, {
status: status === "paused" ? "pending" : "paused",
})
}}
>
{status === "paused" ? (
<>
<PlayCircleIcon className="me-2.5 size-4" />
<Trans>Resume</Trans>
</>
) : (
<>
<PauseCircleIcon className="me-2.5 size-4" />
<Trans>Pause</Trans>
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => copyToClipboard(host)}>
<CopyIcon className="me-2.5 size-4" />
<Trans>Copy host</Trans>
</DropdownMenuItem>
<DropdownMenuSeparator className={cn(isReadOnlyUser() && "hidden")} />
<DropdownMenuItem className={cn(isReadOnlyUser() && "hidden")} onSelect={() => setDeleteOpen(true)}>
<Trash2Icon className="me-2.5 size-4" />
<Trans>Delete</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* edit dialog */}
<Dialog open={editOpen} onOpenChange={setEditOpen}>
{editOpened.current && <SystemDialog system={system} setOpen={setEditOpen} />}
</Dialog>
{/* deletion dialog */}
<AlertDialog open={deleteOpen} onOpenChange={(open) => setDeleteOpen(open)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
<Trans>Are you sure you want to delete {name}?</Trans>
</AlertDialogTitle>
<AlertDialogDescription>
<Trans>
This action cannot be undone. This will permanently delete all current records for {name} from the
database.
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
<Trans>Cancel</Trans>
</AlertDialogCancel>
<AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => pb.collection("systems").delete(id)}
>
<Trans>Continue</Trans>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
})

View File

@@ -74,6 +74,7 @@
@layer base { @layer base {
* { * {
@apply border-border; @apply border-border;
overflow-anchor: none;
} }
body { body {
@apply bg-background text-foreground; @apply bg-background text-foreground;

View File

@@ -3,15 +3,7 @@ import { i18n } from "@lingui/core"
import type { Messages } from "@lingui/core" import type { Messages } from "@lingui/core"
import languages from "@/lib/languages" import languages from "@/lib/languages"
import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale" import { detect, fromStorage, fromNavigator } from "@lingui/detect-locale"
import { messages as enMessages } from "@/locales/en/en.ts" import { messages as enMessages } from "@/locales/en/en"
// let locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
let locale = detect(fromStorage("lang"), fromNavigator(), "en")
// log if dev
if (import.meta.env.DEV) {
console.log("detected locale", locale)
}
// activates locale // activates locale
function activateLocale(locale: string, messages: Messages = enMessages) { function activateLocale(locale: string, messages: Messages = enMessages) {
@@ -37,21 +29,28 @@ export async function dynamicActivate(locale: string) {
} }
} }
// handle zh variants export function getLocale() {
if (locale?.startsWith("zh-")) { // let locale = detect(fromUrl("lang"), fromStorage("lang"), fromNavigator(), "en")
// map zh variants to zh-CN let locale = detect(fromStorage("lang"), fromNavigator(), "en")
const zhVariantMap: Record<string, string> = { // log if dev
"zh-HK": "zh-HK", if (import.meta.env.DEV) {
"zh-TW": "zh", console.log("detected locale", locale)
"zh-MO": "zh", }
"zh-Hant": "zh", // handle zh variants
if (locale?.startsWith("zh-")) {
// map zh variants to zh-CN
const zhVariantMap: Record<string, string> = {
"zh-HK": "zh-HK",
"zh-TW": "zh",
"zh-MO": "zh",
"zh-Hant": "zh",
}
return zhVariantMap[locale] || "zh-CN"
} }
dynamicActivate(zhVariantMap[locale] || "zh-CN")
} else {
locale = (locale || "en").split("-")[0] locale = (locale || "en").split("-")[0]
// use en if locale is not in languages // use en if locale is not in languages
if (!languages.some((l) => l.lang === locale)) { if (!languages.some((l) => l.lang === locale)) {
locale = "en" locale = "en"
} }
dynamicActivate(locale) return locale
} }

View File

@@ -18,9 +18,6 @@ export const $alerts = atom([] as AlertRecord[])
/** SSH public key */ /** SSH public key */
export const $publicKey = atom("") export const $publicKey = atom("")
/** Beszel hub version */
export const $hubVersion = atom("")
/** Chart time period */ /** Chart time period */
export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes> export const $chartTime = atom("1h") as PreinitializedWritableAtom<ChartTimes>

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro";
import { toast } from "@/components/ui/use-toast" import { toast } from "@/components/ui/use-toast"
import { type ClassValue, clsx } from "clsx" import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
@@ -9,13 +10,21 @@ import { timeDay, timeHour } from "d3-time"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react" import { CpuIcon, HardDriveIcon, MemoryStickIcon, ServerIcon } from "lucide-react"
import { EthernetIcon, ThermometerIcon } from "@/components/ui/icons" import { EthernetIcon, ThermometerIcon } from "@/components/ui/icons"
import { t } from "@lingui/macro"
import { prependBasePath } from "@/components/router" import { prependBasePath } from "@/components/router"
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) 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) { export async function copyToClipboard(content: string) {
const duration = 1500 const duration = 1500
@@ -68,6 +77,7 @@ export const updateSystemList = (() => {
/** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */ /** Logs the user out by clearing the auth store and unsubscribing from realtime updates. */
export async function logOut() { export async function logOut() {
sessionStorage.setItem("lo", "t")
pb.authStore.clear() pb.authStore.clear()
pb.realtime.unsubscribe() 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

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,11 @@
import "./index.css" import "./index.css"
// import { Suspense, lazy, useEffect, StrictMode } from "react" // import { Suspense, lazy, useEffect, StrictMode } from "react"
import { Suspense, lazy, useEffect } from "react" import { Suspense, lazy, memo, useEffect } from "react"
import ReactDOM from "react-dom/client" import ReactDOM from "react-dom/client"
import Home from "./components/routes/home.tsx" import { Home } from "./components/routes/home.tsx"
import { ThemeProvider } from "./components/theme-provider.tsx" import { ThemeProvider } from "./components/theme-provider.tsx"
import { DirectionProvider } from "@radix-ui/react-direction" import { DirectionProvider } from "@radix-ui/react-direction"
import { $authenticated, $systems, pb, $publicKey, $hubVersion, $copyContent, $direction } from "./lib/stores.ts" import { $authenticated, $systems, pb, $publicKey, $copyContent, $direction } from "./lib/stores.ts"
import { updateUserSettings, updateAlerts, updateFavicon, updateSystemList } from "./lib/utils.ts" import { updateUserSettings, updateAlerts, updateFavicon, updateSystemList } from "./lib/utils.ts"
import { useStore } from "@nanostores/react" import { useStore } from "@nanostores/react"
import { Toaster } from "./components/ui/toaster.tsx" import { Toaster } from "./components/ui/toaster.tsx"
@@ -14,13 +14,14 @@ import SystemDetail from "./components/routes/system.tsx"
import Navbar from "./components/navbar.tsx" import Navbar from "./components/navbar.tsx"
import { I18nProvider } from "@lingui/react" import { I18nProvider } from "@lingui/react"
import { i18n } from "@lingui/core" import { i18n } from "@lingui/core"
import { getLocale, dynamicActivate } from "./lib/i18n.ts"
// const ServerDetail = lazy(() => import('./components/routes/system.tsx')) // const ServerDetail = lazy(() => import('./components/routes/system.tsx'))
const LoginPage = lazy(() => import("./components/login/login.tsx")) const LoginPage = lazy(() => import("./components/login/login.tsx"))
const CopyToClipboardDialog = lazy(() => import("./components/copy-to-clipboard.tsx")) const CopyToClipboardDialog = lazy(() => import("./components/copy-to-clipboard.tsx"))
const Settings = lazy(() => import("./components/routes/settings/layout.tsx")) const Settings = lazy(() => import("./components/routes/settings/layout.tsx"))
const App = () => { const App = memo(() => {
const page = useStore($router) const page = useStore($router)
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
const systems = useStore($systems) const systems = useStore($systems)
@@ -33,7 +34,6 @@ const App = () => {
// get version / public key // get version / public key
pb.send("/api/beszel/getkey", {}).then((data) => { pb.send("/api/beszel/getkey", {}).then((data) => {
$publicKey.set(data.key) $publicKey.set(data.key)
$hubVersion.set(data.v)
}) })
// get servers / alerts / settings // get servers / alerts / settings
updateUserSettings() updateUserSettings()
@@ -74,7 +74,7 @@ const App = () => {
</Suspense> </Suspense>
) )
} }
} })
const Layout = () => { const Layout = () => {
const authenticated = useStore($authenticated) const authenticated = useStore($authenticated)
@@ -110,15 +110,25 @@ const Layout = () => {
) )
} }
const I18nApp = () => {
useEffect(() => {
dynamicActivate(getLocale())
}, [])
return (
<I18nProvider i18n={i18n}>
<ThemeProvider>
<Layout />
<Toaster />
</ThemeProvider>
</I18nProvider>
)
}
ReactDOM.createRoot(document.getElementById("app")!).render( ReactDOM.createRoot(document.getElementById("app")!).render(
// strict mode in dev mounts / unmounts components twice // strict mode in dev mounts / unmounts components twice
// and breaks the clipboard dialog // and breaks the clipboard dialog
//<StrictMode> //<StrictMode>
<I18nProvider i18n={i18n}> <I18nApp />
<ThemeProvider>
<Layout />
<Toaster />
</ThemeProvider>
</I18nProvider>
//</StrictMode> //</StrictMode>
) )

View File

@@ -2,8 +2,9 @@ import { RecordModel } from "pocketbase"
// global window properties // global window properties
declare global { declare global {
interface Window { var BESZEL: {
BASE_PATH: string BASE_PATH: string
HUB_VERSION: string
} }
} }

View File

@@ -2,6 +2,7 @@ import { defineConfig } from "vite"
import path from "path" import path from "path"
import react from "@vitejs/plugin-react-swc" import react from "@vitejs/plugin-react-swc"
import { lingui } from "@lingui/vite-plugin" import { lingui } from "@lingui/vite-plugin"
import { version } from "./package.json"
export default defineConfig({ export default defineConfig({
base: "./", base: "./",
@@ -10,6 +11,13 @@ export default defineConfig({
plugins: [["@lingui/swc-plugin", {}]], plugins: [["@lingui/swc-plugin", {}]],
}), }),
lingui(), lingui(),
{
name: "replace version in index.html during dev",
apply: "serve",
transformIndexHtml(html) {
return html.replace("{{V}}", version)
},
},
], ],
esbuild: { esbuild: {
legalComments: "external", legalComments: "external",

View File

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

View File

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

View File

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