mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-27 07:56:19 +01:00
Compare commits
57 Commits
7fee3da2e8
...
v0.10.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7057f2e917 | ||
|
|
47b2689f24 | ||
|
|
9b65110aef | ||
|
|
3935a9bf00 | ||
|
|
fb2adf08dc | ||
|
|
61441b115b | ||
|
|
3ad78a2588 | ||
|
|
81514d4deb | ||
|
|
faeb801512 | ||
|
|
968ca70670 | ||
|
|
5837b4f25c | ||
|
|
c38d04b34b | ||
|
|
cadc09b493 | ||
|
|
edefc6f53e | ||
|
|
400ea89587 | ||
|
|
3058c24e82 | ||
|
|
521be05bc1 | ||
|
|
6b766b2653 | ||
|
|
d36b8369cc | ||
|
|
ae22334645 | ||
|
|
1d7c0ebc27 | ||
|
|
3b9910351d | ||
|
|
f397ab0797 | ||
|
|
b1fc715ec9 | ||
|
|
d25c7c58c1 | ||
|
|
a6daa70010 | ||
|
|
d722e4712c | ||
|
|
1d61ad5d7c | ||
|
|
28589455bf | ||
|
|
dd21c18939 | ||
|
|
fd79bc3341 | ||
|
|
7edcf8db85 | ||
|
|
245a047062 | ||
|
|
520b52e532 | ||
|
|
c421ffac70 | ||
|
|
6767392ea8 | ||
|
|
25b73bfb85 | ||
|
|
5fbc0de07f | ||
|
|
c8130a10d4 | ||
|
|
0619eabec2 | ||
|
|
5b4d5c648e | ||
|
|
0443a85015 | ||
|
|
c4d8deb986 | ||
|
|
681286eb4f | ||
|
|
99cdb196ca | ||
|
|
31431fd211 | ||
|
|
9e56f4611f | ||
|
|
a1f6eeb9eb | ||
|
|
f8a1d9fc5d | ||
|
|
d81db6e319 | ||
|
|
17a163de26 | ||
|
|
85db31a8cd | ||
|
|
327db38953 | ||
|
|
0413368762 | ||
|
|
db73928604 | ||
|
|
add1b27346 | ||
|
|
2ef1fe6b2a |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -15,4 +15,5 @@ beszel/build
|
|||||||
*timestamp*
|
*timestamp*
|
||||||
.swc
|
.swc
|
||||||
beszel/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
*.bak
|
*.bak
|
||||||
|
__debug_*
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
183
beszel/go.sum
183
beszel/go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
36
beszel/internal/agent/agent_cache.go
Normal file
36
beszel/internal/agent/agent_cache.go
Normal 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()
|
||||||
|
}
|
||||||
85
beszel/internal/agent/agent_cache_test.go
Normal file
85
beszel/internal/agent/agent_cache_test.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
18
beszel/internal/agent/health.go
Normal file
18
beszel/internal/agent/health.go
Normal 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
|
||||||
|
}
|
||||||
118
beszel/internal/agent/health_test.go
Normal file
118
beszel/internal/agent/health_test.go
Normal 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")
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
|||||||
@@ -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},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
435
beszel/internal/hub/systems/systems.go
Normal file
435
beszel/internal/hub/systems/systems.go
Normal 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
|
||||||
|
}
|
||||||
440
beszel/internal/hub/systems/systems_test.go
Normal file
440
beszel/internal/hub/systems/systems_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
117
beszel/internal/hub/systems/systems_test_helpers.go
Normal file
117
beszel/internal/hub/systems/systems_test_helpers.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
58
beszel/internal/tests/hub.go
Normal file
58
beszel/internal/tests/hub.go
Normal 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
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
676
beszel/migrations/collections_snapshot_0_10_2.go
Normal file
676
beszel/migrations/collections_snapshot_0_10_2.go
Normal 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.
@@ -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>
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
2568
beszel/site/package-lock.json
generated
2568
beszel/site/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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 () {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -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>("")
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
3
beszel/site/src/types.d.ts
vendored
3
beszel/site/src/types.d.ts
vendored
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package beszel
|
package beszel
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Version = "0.9.1"
|
Version = "0.10.2"
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user