mirror of
https://github.com/henrygd/beszel.git
synced 2026-03-23 22:16:18 +01:00
Compare commits
106 Commits
feat-allow
...
v0.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5c3d8f84e | ||
|
|
8f442992e6 | ||
|
|
39820c8ac1 | ||
|
|
0c8b10af99 | ||
|
|
8e072492b7 | ||
|
|
88d6307ce0 | ||
|
|
2cc516f9e5 | ||
|
|
ab6ea71695 | ||
|
|
6280323cb1 | ||
|
|
17c8e7e1bd | ||
|
|
f60fb6f8a9 | ||
|
|
3eebbce2d4 | ||
|
|
e92a94a24d | ||
|
|
7c7c073ae4 | ||
|
|
c009a40749 | ||
|
|
5e85b803e0 | ||
|
|
256d3c5ba1 | ||
|
|
bd048a8989 | ||
|
|
f6b4231500 | ||
|
|
bda06f30b3 | ||
|
|
38f2ba3984 | ||
|
|
1a7d897bdc | ||
|
|
c74e7430ef | ||
|
|
2467bbc0f0 | ||
|
|
ea665e02da | ||
|
|
358e05d544 | ||
|
|
aab5725d82 | ||
|
|
e94a1cd421 | ||
|
|
73c1a1b208 | ||
|
|
0526c88ce0 | ||
|
|
a2e9056a00 | ||
|
|
fd4ac60908 | ||
|
|
330e4c67f3 | ||
|
|
5d840bd473 | ||
|
|
54e3f3eba1 | ||
|
|
d79111fce4 | ||
|
|
93c3c7b9d8 | ||
|
|
410d236f89 | ||
|
|
9a8071c314 | ||
|
|
80df0efccd | ||
|
|
3f1f4c7596 | ||
|
|
04ac688be4 | ||
|
|
ace83172ff | ||
|
|
e8b864b515 | ||
|
|
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 | ||
|
|
2b43ba3cbe | ||
|
|
b2b1a0b6ea | ||
|
|
b11d0aae61 | ||
|
|
2b73d8845a | ||
|
|
41e3e3d760 |
2
.github/workflows/docker-images.yml
vendored
2
.github/workflows/docker-images.yml
vendored
@@ -3,7 +3,7 @@ name: Make docker images
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'xv*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Make release and binaries
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- '*'
|
- 'v*'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -14,4 +14,6 @@ node_modules
|
|||||||
beszel/build
|
beszel/build
|
||||||
*timestamp*
|
*timestamp*
|
||||||
.swc
|
.swc
|
||||||
beszel/site/src/locales/**/*.ts
|
beszel/site/src/locales/**/*.ts
|
||||||
|
*.bak
|
||||||
|
__debug_*
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ builds:
|
|||||||
- linux
|
- linux
|
||||||
- darwin
|
- darwin
|
||||||
- freebsd
|
- freebsd
|
||||||
|
- openbsd
|
||||||
- windows
|
- windows
|
||||||
goarch:
|
goarch:
|
||||||
- amd64
|
- amd64
|
||||||
@@ -39,6 +40,8 @@ builds:
|
|||||||
ignore:
|
ignore:
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
|
- goos: openbsd
|
||||||
|
goarch: arm
|
||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: arm
|
goarch: arm
|
||||||
- goos: darwin
|
- goos: darwin
|
||||||
@@ -47,7 +50,7 @@ builds:
|
|||||||
goarch: riscv64
|
goarch: riscv64
|
||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: beszel
|
- id: beszel-agent
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
builds:
|
builds:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
@@ -59,7 +62,7 @@ archives:
|
|||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
format: zip
|
||||||
|
|
||||||
- id: beszel-agent
|
- id: beszel
|
||||||
format: tar.gz
|
format: tar.gz
|
||||||
builds:
|
builds:
|
||||||
- beszel
|
- beszel
|
||||||
@@ -111,6 +114,65 @@ nfpms:
|
|||||||
# https://github.com/goreleaser/goreleaser/issues/5487
|
# https://github.com/goreleaser/goreleaser/issues/5487
|
||||||
#config: ../supplemental/debian/config.sh
|
#config: ../supplemental/debian/config.sh
|
||||||
|
|
||||||
|
scoops:
|
||||||
|
- ids: [beszel-agent]
|
||||||
|
name: beszel-agent
|
||||||
|
repository:
|
||||||
|
owner: henrygd
|
||||||
|
name: beszel-scoops
|
||||||
|
homepage: 'https://beszel.dev'
|
||||||
|
description: 'Agent for Beszel, a lightweight server monitoring platform.'
|
||||||
|
license: MIT
|
||||||
|
|
||||||
|
# # Needs choco installed, so doesn't build on linux / default gh workflow :(
|
||||||
|
# chocolateys:
|
||||||
|
# - title: Beszel Agent
|
||||||
|
# ids: [beszel-agent]
|
||||||
|
# package_source_url: https://github.com/henrygd/beszel-chocolatey
|
||||||
|
# owners: henrygd
|
||||||
|
# authors: henrygd
|
||||||
|
# summary: 'Agent for Beszel, a lightweight server monitoring platform.'
|
||||||
|
# description: |
|
||||||
|
# Beszel is a lightweight server monitoring platform that includes Docker statistics, historical data, and alert functions.
|
||||||
|
|
||||||
|
# It has a friendly web interface, simple configuration, and is ready to use out of the box. It supports automatic backup, multi-user, OAuth authentication, and API access.
|
||||||
|
# license_url: https://github.com/henrygd/beszel/blob/main/LICENSE
|
||||||
|
# project_url: https://beszel.dev
|
||||||
|
# project_source_url: https://github.com/henrygd/beszel
|
||||||
|
# docs_url: https://beszel.dev/guide/getting-started
|
||||||
|
# icon_url: https://cdn.jsdelivr.net/gh/selfhst/icons/png/beszel.png
|
||||||
|
# bug_tracker_url: https://github.com/henrygd/beszel/issues
|
||||||
|
# copyright: 2025 henrygd
|
||||||
|
# tags: foss cross-platform admin monitoring
|
||||||
|
# require_license_acceptance: false
|
||||||
|
# release_notes: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}'
|
||||||
|
|
||||||
|
brews:
|
||||||
|
- ids: [beszel-agent]
|
||||||
|
name: beszel-agent
|
||||||
|
repository:
|
||||||
|
owner: henrygd
|
||||||
|
name: homebrew-beszel
|
||||||
|
homepage: 'https://beszel.dev'
|
||||||
|
description: 'Agent for Beszel, a lightweight server monitoring platform.'
|
||||||
|
license: MIT
|
||||||
|
extra_install: |
|
||||||
|
(bin/"beszel-agent-launcher").write <<~EOS
|
||||||
|
#!/bin/bash
|
||||||
|
set -a
|
||||||
|
if [ -f "$HOME/.config/beszel/beszel-agent.env" ]; then
|
||||||
|
source "$HOME/.config/beszel/beszel-agent.env"
|
||||||
|
fi
|
||||||
|
set +a
|
||||||
|
exec #{bin}/beszel-agent "$@"
|
||||||
|
EOS
|
||||||
|
(bin/"beszel-agent-launcher").chmod 0755
|
||||||
|
service: |
|
||||||
|
run ["#{bin}/beszel-agent-launcher"]
|
||||||
|
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
|
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
|
keep_alive true
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +1,48 @@
|
|||||||
module beszel
|
module beszel
|
||||||
|
|
||||||
go 1.24.0
|
go 1.24.2
|
||||||
|
|
||||||
|
// lock shoutrrr to specific version to allow review before updating
|
||||||
|
replace github.com/nicholas-fedor/shoutrrr => github.com/nicholas-fedor/shoutrrr v0.8.8
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/containrrr/shoutrrr v0.8.0
|
|
||||||
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/nicholas-fedor/shoutrrr v0.8.8
|
||||||
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.27.1
|
||||||
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.3
|
||||||
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.37.0
|
||||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
|
||||||
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/aws/protocol/eventstream v1.6.8 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.29.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 // indirect
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 // indirect
|
|
||||||
github.com/aws/smithy-go v1.22.2 // indirect
|
|
||||||
github.com/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
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.2 // indirect
|
||||||
github.com/fatih/color v1.18.0 // indirect
|
github.com/fatih/color v1.18.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 // indirect
|
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
|
github.com/go-sql-driver/mysql v1.9.1 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // 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-20250317134145-8bc96cf8fc35 // 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
|
||||||
@@ -68,26 +51,18 @@ require (
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
github.com/tcnksm/go-gitconfig v0.1.2 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.14 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.9.0 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/ulikunitz/xz v0.5.12 // indirect
|
github.com/ulikunitz/xz v0.5.12 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
golang.org/x/image v0.26.0 // indirect
|
||||||
gocloud.dev v0.40.0 // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
golang.org/x/image v0.24.0 // indirect
|
golang.org/x/oauth2 v0.29.0 // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
golang.org/x/oauth2 v0.26.0 // indirect
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/sync v0.11.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
golang.org/x/sys v0.30.0 // indirect
|
modernc.org/libc v1.64.0 // indirect
|
||||||
golang.org/x/term v0.29.0 // indirect
|
|
||||||
golang.org/x/text v0.22.0 // indirect
|
|
||||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
|
||||||
google.golang.org/api v0.220.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 // indirect
|
|
||||||
google.golang.org/grpc v1.70.0 // indirect
|
|
||||||
google.golang.org/protobuf v1.36.4 // indirect
|
|
||||||
modernc.org/libc v1.55.3 // 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.10.0 // indirect
|
||||||
modernc.org/sqlite v1.34.5 // indirect
|
modernc.org/sqlite v1.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
338
beszel/go.sum
338
beszel/go.sum
@@ -1,75 +1,14 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
|
||||||
cloud.google.com/go v0.115.0 h1:CnFSK6Xo3lDYRoBKEcAtia6VSC837/ZkJuRduSFnr14=
|
|
||||||
cloud.google.com/go v0.115.0/go.mod h1:8jIM5vVgoAEoiVxQ/O4BFTfHqulPZgs/ufEzMcFMdWU=
|
|
||||||
cloud.google.com/go/auth v0.14.1 h1:AwoJbzUdxA/whv1qj3TLKwh3XX5sikny2fc40wUl+h0=
|
|
||||||
cloud.google.com/go/auth v0.14.1/go.mod h1:4JHUxlGXisL0AW8kXPtUF6ztuOksyfUQNFjfsOCXkPM=
|
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M=
|
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc=
|
|
||||||
cloud.google.com/go/compute v1.14.0 h1:hfm2+FfxVmnRlh6LpB7cg1ZNU+5edAHmW679JePztk0=
|
|
||||||
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
|
||||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
|
||||||
cloud.google.com/go/iam v1.1.13 h1:7zWBXG9ERbMLrzQBRhFliAV+kjcRToDTgQT3CTwYyv4=
|
|
||||||
cloud.google.com/go/iam v1.1.13/go.mod h1:K8mY0uSXwEXS30KrnVb+j54LB/ntfZu1dr+4zFMNbus=
|
|
||||||
cloud.google.com/go/storage v1.43.0 h1:CcxnSohZwizt4LCzQHWvBf1/kvtHUn7gk9QERXPyXFs=
|
|
||||||
cloud.google.com/go/storage v1.43.0/go.mod h1:ajvxEa7WmZS1PxvKRq4bq0tFT3vMd502JwstCcYv0Q0=
|
|
||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg=
|
||||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||||
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/go.mod h1:eRwEWoyTWFMVYVQzKMNHWP5/RV4xIUGMQfXQHfHkpNU=
|
|
||||||
github.com/aws/aws-sdk-go-v2 v1.36.1 h1:iTDl5U6oAhkNPba0e1t1hrwAo02ZMqbrGq4k5JBWM5E=
|
|
||||||
github.com/aws/aws-sdk-go-v2 v1.36.1/go.mod h1:5PMILGVKiW32oDzjj6RU52yrNrDPUHcbZQYr1sM7qmM=
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8 h1:zAxi9p3wsZMIaVCdoiQp2uZ9k1LsZvmAnoTBeZPXom0=
|
|
||||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.8/go.mod h1:3XkePX5dSaxveLAYY7nsbsZZrKxCyEuE5pM4ziFxyGg=
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.29.6 h1:fqgqEKK5HaZVWLQoLiC9Q+xDlSp+1LYidp6ybGE2OGg=
|
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.29.6/go.mod h1:Ft+WLODzDQmCTHDvqAH1JfC2xxbZ0MxpZAcJqmE1LTQ=
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.59 h1:9btwmrt//Q6JcSdgJOLI98sdr5p7tssS9yAsGe8aKP4=
|
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.59/go.mod h1:NM8fM6ovI3zak23UISdWidyZuI1ghNe2xjzUZAyT+08=
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28 h1:KwsodFKVQTlI5EyhRSugALzsV6mG/SGrdjlMXSZSdso=
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.28/go.mod h1:EY3APf9MzygVhKuPXAc5H+MkGb8k/DOSQjWS0LgkKqI=
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59 h1:5Vsrfdlf9KQP3leGX1dD7VwZq/3HAerEFoXAII4t6zo=
|
|
||||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.59/go.mod h1:7XTNs3NYApJjkx6A2Fk9qq23qBuBnIU58k3fKC2Fr1I=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32 h1:BjUcr3X3K0wZPGFg2bxOWW3VPN8rkE3/61zhP+IHviA=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.32/go.mod h1:80+OGC/bgzzFFTUmcuwD0lb4YutwQeKLFpmt6hoWapU=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32 h1:m1GeXHVMJsRsUAqG6HjZWx9dj7F5TR+cF1bjyfYyBd4=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.32/go.mod h1:IitoQxGfaKdVLNg0hD8/DXmAqNy0H4K2H2Sf91ti8sI=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2 h1:Pg9URiobXy85kgFev3og2CuOZ8JZUBENF+dcgWBaYNk=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.2/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32 h1:OIHj/nAhVzIXGzbAE+4XmZ8FPvro3THr6NlqErJc3wY=
|
|
||||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.32/go.mod h1:LiBEsDo34OJXqdDlRGsilhlIiXR7DL+6Cx2f4p1EgzI=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2 h1:D4oz8/CzT9bAEYtVhSBmFj2dNOtaHOtMKc2vHBwYizA=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.2/go.mod h1:Za3IHqTQ+yNcRHxu1OFucBh0ACZT4j4VQFF0BqpZcLY=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6 h1:cCBJaT7EeEojpJ4s7wTDbhZlHVJOgNHN7iw6qVurGaw=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.5.6/go.mod h1:WYH1ABybY7JK9TITPnk6ZlP7gQB8psI4c9qDmMsnLSA=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13 h1:SYVGSFQHlchIcy6e7x12bsrxClCXSP5et8cqVhL8cuw=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.13/go.mod h1:kizuDaLX37bG5WZaoxGPQR/LNFXpxp0vsUnqfkWXfNE=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13 h1:OBsrtam3rk8NfBEq7OLOMm5HtQ9Yyw32X4UQMya/wjw=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.13/go.mod h1:3U4gFA5pmoCOja7aq4nSaIAGbaOHv2Yl2ug018cmC+Q=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4 h1:DJYjOvNgC30JAcDCRmtQHoYK4trc7XetDXRTEAReGKA=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.75.4/go.mod h1:KuLNrwYJFaC2AVZ+CVVc12k9NyqwgWsoNNHjwqF6QNk=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15 h1:/eE3DogBjYlvlbhd2ssWyeuovWunHLxfgw3s/OJa4GQ=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.24.15/go.mod h1:2PCJYpi7EKeA5SkStAmZlF6fi0uUABuhtF8ILHjGc3Y=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14 h1:M/zwXiL2iXUrHputuXgmO94TVNmcenPHxgLXLutodKE=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.14/go.mod h1:RVwIw3y/IqxC2YEXSIkAzRDdEU1iRabDPaYjpGCbCGQ=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14 h1:TzeR06UCMUq+KA3bDkujxK1GVGy+G8qQN/QVYzGLkQE=
|
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.33.14/go.mod h1:dspXf/oYWGWo6DEvj98wpaTeqt5+DMidZD0A9BYTizc=
|
|
||||||
github.com/aws/smithy-go v1.22.2 h1:6D9hW43xKFrRx/tXXfAlIZc4JI+yQe6snnWcQyxSyLQ=
|
|
||||||
github.com/aws/smithy-go v1.22.2/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
|
|
||||||
github.com/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/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
|
||||||
github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec=
|
|
||||||
github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/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.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
@@ -80,94 +19,55 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
|||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
|
||||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
|
||||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||||
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1 h1:hpUgbUEEWIZhSDBtf4M9aUNfQQ0BZkGRaMePy7Gcx5k=
|
github.com/ganigeorgiev/fexpr v0.5.0 h1:XA9JxtTE/Xm+g/JFI6RfZEHSiQlk+1glLvRK1Lpv/Tk=
|
||||||
github.com/ganigeorgiev/fexpr v0.4.1/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
github.com/ganigeorgiev/fexpr v0.5.0/go.mod h1:RyGiGqmeXhEQ6+mlGdnUleLHgtzzu/VGO2WtJkF5drE=
|
||||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
|
||||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
|
||||||
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
|
||||||
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.9.1 h1:FrjNGn/BsJQjVRuSa8CBrM5BWA9BWoXXat3KrtSb/iI=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.9.1/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
|
github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
|
||||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
|
||||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
|
||||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
|
||||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
|
||||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
|
||||||
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
|
||||||
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
|
||||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
|
||||||
github.com/google/go-cmp v0.4.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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
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=
|
||||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 h1:FKHo8hFI3A+7w0aUQuYXQ+6EN5stWmeY/AZqtM8xk9k=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8/go.mod h1:K1liHPHnj73Fdn/EKuT8nrFqBihUSKXoLYU0BuatOYo=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0=
|
|
||||||
github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM=
|
|
||||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/wire v0.6.0 h1:HBkoIh4BdSxoyo9PveV8giw7ZsaBOvzWKfcg/6MrVwI=
|
|
||||||
github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA=
|
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
|
||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q=
|
|
||||||
github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA=
|
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf h1:WfD7VjIE6z8dIvMsI4/s+1qr5EL+zoIGev1BQj1eoJ8=
|
||||||
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc=
|
github.com/jarcoal/httpmock v1.4.0 h1:BvhqnH0JAYbNudL2GMJKgOHe2CtKlzJ/5rWKyp+hc2k=
|
||||||
github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg=
|
github.com/jarcoal/httpmock v1.4.0/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
|
||||||
github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg=
|
|
||||||
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
|
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -175,31 +75,32 @@ 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-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||||
github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/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=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/nicholas-fedor/shoutrrr v0.8.8 h1:F/oyoatWK5cbHPPgkjRZrA0262TP7KWuUQz9KskRtR8=
|
||||||
|
github.com/nicholas-fedor/shoutrrr v0.8.8/go.mod h1:T30Y+eoZFEjDk4HtOItcHQioZSOe3Z6a6aNfSz6jc5c=
|
||||||
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
|
github.com/onsi/ginkgo v1.6.0 h1:Ix8l273rp3QzYgXSR+c8d1fTG7UPgYkOSELPhiY/YGw=
|
||||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
|
||||||
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
|
github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
|
||||||
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.4.2/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||||
github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg=
|
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
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.27.1 h1:KGCsS8idUVTC5QHxTj91qHDhIXOb5Yb50wwHhNvJRTQ=
|
||||||
github.com/pocketbase/pocketbase v0.25.0/go.mod h1:tOtOv7f3vJhAiyUluIwV9JPuKeknZRQ9F6uJE3W/ntI=
|
github.com/pocketbase/pocketbase v0.27.1/go.mod h1:aTpwwloVJzeJ7MlwTRrbI/x62QNR2/kkCrovmyrXpqs=
|
||||||
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
||||||
@@ -207,156 +108,82 @@ 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.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.1/go.mod h1:RoUCUpndaJFtT+2zsZzzmhvbfGoDCJ7nFXKJf8GqJbI=
|
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||||
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=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
github.com/tcnksm/go-gitconfig v0.1.2 h1:iiDhRitByXAEyjgBqsKi9QU4o2TNtv9kPP3RgPgXBPw=
|
||||||
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
github.com/tcnksm/go-gitconfig v0.1.2/go.mod h1:/8EhP4H7oJZdIPyT+/UIsG87kTzrzM4UsLGSItWYCpE=
|
||||||
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
|
||||||
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0 h1:PS8wXpbyaDJQ2VDHHncMe9Vct0Zn1fEjpsjrLxGJoSc=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.58.0/go.mod h1:HDBUsEjOuRC0EzKZ1bSaRGZWUBAzo+MhAcUUORSr4D0=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
|
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
|
|
||||||
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
|
|
||||||
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
|
|
||||||
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
|
|
||||||
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
|
|
||||||
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
|
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
|
|
||||||
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
|
|
||||||
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
|
|
||||||
gocloud.dev v0.40.0 h1:f8LgP+4WDqOG/RXoUcyLpeIAGOcAbZrZbDQCUee10ng=
|
|
||||||
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-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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c h1:KL/ZBHXgKGVmuZBZ01Lt57yE5ws8ZPSkkihmEyq7FXc=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/exp v0.0.0-20250128182459-e0ece0dbea4c/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
|
||||||
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.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
|
||||||
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
|
|
||||||
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
|
|
||||||
golang.org/x/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-20180906233101-161cd47e91fd/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-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
|
||||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
|
||||||
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.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
|
||||||
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
|
golang.org/x/oauth2 v0.29.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.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
|
|
||||||
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU=
|
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||||
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
|
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
|
||||||
golang.org/x/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.32.0 h1:Q7N1vhpkQv7ybVzLFtTjvQya2ewbwNDZzUgfXGqtMWU=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.32.0/go.mod h1:ZxrU41P/wAbZD8EDa6dDCa6XfpkhJ7HFMjHJXfBDu8s=
|
||||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
|
||||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
|
||||||
golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE=
|
|
||||||
golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588=
|
|
||||||
golang.org/x/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/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
|
|
||||||
google.golang.org/api v0.220.0 h1:3oMI4gdBgB72WFVwE1nerDD8W3HUOS4kypK6rRLbGns=
|
|
||||||
google.golang.org/api v0.220.0/go.mod h1:26ZAlY6aN/8WgpCzjPNy18QpYaz7Zgg1h0qe1GkZEmY=
|
|
||||||
google.golang.org/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.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
|
||||||
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988 h1:CT2Thj5AuPV9phrYMtzX11k+XkzMGfRAet42PmoTATM=
|
|
||||||
google.golang.org/genproto v0.0.0-20240812133136-8ffd90a71988/go.mod h1:7uvplUBj4RjHAxIZ//98LzOvrQ04JBkaixRmCMI29hc=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489 h1:5bKytslY8ViY0Cj/ewmRtrWHW64bNF03cAatUUFCdFI=
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250204164813-702378808489/go.mod h1:8BS3B93F/U1juMFq9+EDk+qOT5CO1R9IzXxG3PTqiRk=
|
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
|
||||||
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
|
||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
|
||||||
google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ=
|
|
||||||
google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
|
||||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
|
||||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
|
||||||
google.golang.org/protobuf v1.22.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.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
|
||||||
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
|
|
||||||
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
|
||||||
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=
|
||||||
@@ -364,32 +191,29 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy
|
|||||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
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=
|
modernc.org/cc/v4 v4.26.0 h1:QMYvbVduUGH0rrO+5mqF/PSPPRZNpRtg2CLELy7vUpA=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
modernc.org/cc/v4 v4.26.0/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/ccgo/v4 v4.26.0 h1:gVzXaDzGeBYJ2uXTOpR8FR7OlksDOe9jxnjhIKCsiTc=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/ccgo/v4 v4.26.0/go.mod h1:Sem8f7TFUtVXkG2fiaChQtyyfkqhJBg/zjEJBkmuAVY=
|
||||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||||
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
|
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
|
modernc.org/libc v1.64.0 h1:U0k8BD2d3cD3e9I8RLcZgJBHAcsJzbXx5mKGSb5pyJA=
|
||||||
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
|
modernc.org/libc v1.64.0/go.mod h1:7m9VzGq7APssBTydds2zBcxGREwvIGpuUBaKTXdm2Qs=
|
||||||
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
|
|
||||||
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
|
|
||||||
modernc.org/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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||||
modernc.org/memory v1.8.2/go.mod h1:ZbjSvMO5NQ1A2i3bWeDiVMxIorXwdClKE/0SZ+BMotU=
|
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
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.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||||
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
|
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||||
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=
|
||||||
|
|||||||
@@ -4,37 +4,36 @@ package agent
|
|||||||
import (
|
import (
|
||||||
"beszel"
|
"beszel"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"context"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
"github.com/shirou/gopsutil/v4/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
zfs bool // true if system has arcstats
|
zfs bool // true if system has arcstats
|
||||||
memCalc string // Memory calculation formula
|
memCalc string // Memory calculation formula
|
||||||
fsNames []string // List of filesystem device names being monitored
|
fsNames []string // List of filesystem device names being monitored
|
||||||
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
fsStats map[string]*system.FsStats // Keeps track of disk stats for each filesystem
|
||||||
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
netInterfaces map[string]struct{} // Stores all valid network interfaces
|
||||||
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
netIoStats system.NetIoStats // Keeps track of bandwidth usage
|
||||||
dockerManager *dockerManager // Manages Docker API requests
|
dockerManager *dockerManager // Manages Docker API requests
|
||||||
sensorsContext context.Context // Sensors context to override sys location
|
sensorConfig *SensorConfig // Sensors config
|
||||||
sensorsWhitelist map[string]struct{} // List of sensors to monitor
|
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),
|
fsStats: make(map[string]*system.FsStats),
|
||||||
|
cache: NewSessionCache(69 * time.Second),
|
||||||
}
|
}
|
||||||
agent.memCalc, _ = GetEnv("MEM_CALC")
|
agent.memCalc, _ = GetEnv("MEM_CALC")
|
||||||
|
agent.sensorConfig = agent.newSensorConfig()
|
||||||
// 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) {
|
||||||
@@ -50,26 +49,6 @@ func NewAgent() *Agent {
|
|||||||
|
|
||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// Set sensors context (allows overriding sys location for sensors)
|
|
||||||
if sysSensors, exists := GetEnv("SYS_SENSORS"); exists {
|
|
||||||
slog.Info("SYS_SENSORS", "path", sysSensors)
|
|
||||||
agent.sensorsContext = context.WithValue(agent.sensorsContext,
|
|
||||||
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
agent.sensorsContext = context.Background()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set sensors whitelist
|
|
||||||
if sensors, exists := GetEnv("SENSORS"); exists {
|
|
||||||
agent.sensorsWhitelist = make(map[string]struct{})
|
|
||||||
for _, sensor := range strings.Split(sensors, ",") {
|
|
||||||
if sensor != "" {
|
|
||||||
agent.sensorsWhitelist[sensor] = struct{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize system info / docker manager
|
// initialize system info / docker manager
|
||||||
agent.initializeSystemInfo()
|
agent.initializeSystemInfo()
|
||||||
agent.initializeDiskInfo()
|
agent.initializeDiskInfo()
|
||||||
@@ -85,7 +64,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 +79,39 @@ 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 a.dockerManager != nil {
|
||||||
systemData.Containers = containerStats
|
if containerStats, err := a.dockerManager.getDockerStats(); err == nil {
|
||||||
slog.Debug("Docker stats", "data", systemData.Containers)
|
cachedData.Containers = containerStats
|
||||||
} else {
|
slog.Debug("Docker stats", "data", cachedData.Containers)
|
||||||
slog.Debug("Error getting docker stats", "err", err)
|
} else {
|
||||||
}
|
slog.Debug("Docker stats", "err", err)
|
||||||
// add extra filesystems
|
|
||||||
systemData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
|
||||||
for name, stats := range a.fsStats {
|
|
||||||
if !stats.Root && stats.DiskTotal > 0 {
|
|
||||||
systemData.Stats.ExtraFs[name] = stats
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slog.Debug("Extra filesystems", "data", systemData.Stats.ExtraFs)
|
|
||||||
return systemData
|
cachedData.Stats.ExtraFs = make(map[string]*system.FsStats)
|
||||||
|
for name, stats := range a.fsStats {
|
||||||
|
if !stats.Root && stats.DiskTotal > 0 {
|
||||||
|
cachedData.Stats.ExtraFs[name] = stats
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Debug("Extra filesystems", "data", cachedData.Stats.ExtraFs)
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -36,7 +37,12 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
|
|
||||||
// Helper function to add a filesystem to fsStats if it doesn't exist
|
// Helper function to add a filesystem to fsStats if it doesn't exist
|
||||||
addFsStat := func(device, mountpoint string, root bool) {
|
addFsStat := func(device, mountpoint string, root bool) {
|
||||||
key := filepath.Base(device)
|
var key string
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
key = device
|
||||||
|
} else {
|
||||||
|
key = filepath.Base(device)
|
||||||
|
}
|
||||||
var ioMatch bool
|
var ioMatch bool
|
||||||
if _, exists := a.fsStats[key]; !exists {
|
if _, exists := a.fsStats[key]; !exists {
|
||||||
if root {
|
if root {
|
||||||
|
|||||||
@@ -22,10 +22,23 @@ 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)
|
||||||
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
@@ -52,11 +65,14 @@ 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)
|
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
||||||
|
|
||||||
|
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 +81,9 @@ 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 _, ctr := range dm.apiContainerList {
|
||||||
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 +100,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 +109,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 +138,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")
|
||||||
@@ -153,22 +169,27 @@ func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if container has valid data, otherwise may be in restart loop (#103)
|
// calculate cpu and memory stats
|
||||||
if res.MemoryStats.Usage == 0 {
|
var usedMemory uint64
|
||||||
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
var cpuPct float64
|
||||||
|
|
||||||
|
if dm.isWindows {
|
||||||
|
usedMemory = res.MemoryStats.PrivateWorkingSet
|
||||||
|
cpuPct = res.CalculateCpuPercentWindows(stats.PrevCpu[0], stats.PrevRead)
|
||||||
|
} else {
|
||||||
|
// check if container has valid data, otherwise may be in restart loop (#103)
|
||||||
|
if res.MemoryStats.Usage == 0 {
|
||||||
|
return fmt.Errorf("%s - no memory stats - see https://github.com/henrygd/beszel/issues/144", name)
|
||||||
|
}
|
||||||
|
memCache := res.MemoryStats.Stats.InactiveFile
|
||||||
|
if memCache == 0 {
|
||||||
|
memCache = res.MemoryStats.Stats.Cache
|
||||||
|
}
|
||||||
|
usedMemory = res.MemoryStats.Usage - memCache
|
||||||
|
|
||||||
|
cpuPct = res.CalculateCpuPercentLinux(stats.PrevCpu)
|
||||||
}
|
}
|
||||||
|
|
||||||
// memory (https://docs.docker.com/reference/cli/docker/container/stats/)
|
|
||||||
memCache := res.MemoryStats.Stats.InactiveFile
|
|
||||||
if memCache == 0 {
|
|
||||||
memCache = res.MemoryStats.Stats.Cache
|
|
||||||
}
|
|
||||||
usedMemory := res.MemoryStats.Usage - memCache
|
|
||||||
|
|
||||||
// cpu
|
|
||||||
cpuDelta := res.CPUStats.CPUUsage.TotalUsage - stats.PrevCpu[0]
|
|
||||||
systemDelta := res.CPUStats.SystemUsage - stats.PrevCpu[1]
|
|
||||||
cpuPct := float64(cpuDelta) / float64(systemDelta) * 100
|
|
||||||
if cpuPct > 100 {
|
if cpuPct > 100 {
|
||||||
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
return fmt.Errorf("%s cpu pct greater than 100: %+v", name, cpuPct)
|
||||||
}
|
}
|
||||||
@@ -183,18 +204,18 @@ func (dm *dockerManager) updateContainerStats(ctr container.ApiInfo) error {
|
|||||||
var sent_delta, recv_delta float64
|
var sent_delta, recv_delta float64
|
||||||
// prevent first run from sending all prev sent/recv bytes
|
// prevent first run from sending all prev sent/recv bytes
|
||||||
if initialized {
|
if initialized {
|
||||||
secondsElapsed := time.Since(stats.PrevNet.Time).Seconds()
|
secondsElapsed := time.Since(stats.PrevRead).Seconds()
|
||||||
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
|
sent_delta = float64(total_sent-stats.PrevNet.Sent) / secondsElapsed
|
||||||
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
|
recv_delta = float64(total_recv-stats.PrevNet.Recv) / secondsElapsed
|
||||||
}
|
}
|
||||||
stats.PrevNet.Sent = total_sent
|
stats.PrevNet.Sent = total_sent
|
||||||
stats.PrevNet.Recv = total_recv
|
stats.PrevNet.Recv = total_recv
|
||||||
stats.PrevNet.Time = time.Now()
|
|
||||||
|
|
||||||
stats.Cpu = twoDecimals(cpuPct)
|
stats.Cpu = twoDecimals(cpuPct)
|
||||||
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
stats.Mem = bytesToMegabytes(float64(usedMemory))
|
||||||
stats.NetworkSent = bytesToMegabytes(sent_delta)
|
stats.NetworkSent = bytesToMegabytes(sent_delta)
|
||||||
stats.NetworkRecv = bytesToMegabytes(recv_delta)
|
stats.NetworkRecv = bytesToMegabytes(recv_delta)
|
||||||
|
stats.PrevRead = res.Read
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -211,6 +232,10 @@ func newDockerManager(a *Agent) *dockerManager {
|
|||||||
dockerHost, exists := GetEnv("DOCKER_HOST")
|
dockerHost, exists := GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
slog.Info("DOCKER_HOST", "host", dockerHost)
|
slog.Info("DOCKER_HOST", "host", dockerHost)
|
||||||
|
// return nil if set to empty string
|
||||||
|
if dockerHost == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dockerHost = getDockerHost()
|
dockerHost = getDockerHost()
|
||||||
}
|
}
|
||||||
@@ -251,20 +276,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 +304,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)
|
||||||
|
|
||||||
@@ -102,36 +125,35 @@ func (gm *GPUManager) getJetsonParser() func(output []byte) bool {
|
|||||||
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
|
// TODO: Maybe use VDD_IN for Nano / NX and add a total system power chart
|
||||||
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
|
powerPattern := regexp.MustCompile(`(GPU_SOC|CPU_GPU_CV) (\d+)mW`)
|
||||||
|
|
||||||
|
// jetson devices have only one gpu so we'll just initialize here
|
||||||
|
gpuData := &system.GPUData{Name: "GPU"}
|
||||||
|
gm.GpuDataMap["0"] = gpuData
|
||||||
|
|
||||||
return func(output []byte) bool {
|
return func(output []byte) bool {
|
||||||
gm.Lock()
|
gm.Lock()
|
||||||
defer gm.Unlock()
|
defer gm.Unlock()
|
||||||
// we get gpu name from the intitial run of nvidia-smi, so return if it hasn't been initialized
|
|
||||||
gpuData, ok := gm.GpuDataMap["0"]
|
|
||||||
if !ok {
|
|
||||||
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 +164,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
|
||||||
@@ -159,18 +183,12 @@ func (gm *GPUManager) parseNvidiaData(output []byte) bool {
|
|||||||
if _, ok := gm.GpuDataMap[id]; !ok {
|
if _, ok := gm.GpuDataMap[id]; !ok {
|
||||||
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
name := strings.TrimPrefix(fields[1], "NVIDIA ")
|
||||||
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
gm.GpuDataMap[id] = &system.GPUData{Name: strings.TrimSuffix(name, " Laptop GPU")}
|
||||||
// check if tegrastats is active - if so we will only use nvidia-smi to get gpu name
|
|
||||||
// - nvidia-smi does not provide metrics for tegra / jetson devices
|
|
||||||
// this will end the nvidia-smi collector
|
|
||||||
if gm.tegrastats {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 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 +259,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,14 +268,15 @@ 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
|
||||||
|
gm.nvidiaSmi = false
|
||||||
}
|
}
|
||||||
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
if gm.nvidiaSmi || gm.rocmSmi || gm.tegrastats {
|
||||||
return nil
|
return nil
|
||||||
@@ -270,17 +290,19 @@ 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 +310,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 +330,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: "",
|
||||||
@@ -202,14 +251,13 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
gm *GPUManager
|
|
||||||
wantMetrics *system.GPUData
|
wantMetrics *system.GPUData
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
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: "GPU",
|
||||||
MemoryUsed: 4300.0,
|
MemoryUsed: 4300.0,
|
||||||
MemoryTotal: 30698.0,
|
MemoryTotal: 30698.0,
|
||||||
Usage: 45.0,
|
Usage: 45.0,
|
||||||
@@ -219,10 +267,23 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "missing temperature",
|
name: "more valid data",
|
||||||
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
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{
|
wantMetrics: &system.GPUData{
|
||||||
Name: "Jetson",
|
Name: "GPU",
|
||||||
|
MemoryUsed: 6185.0,
|
||||||
|
MemoryTotal: 7620.0,
|
||||||
|
Usage: 63.0,
|
||||||
|
Temperature: 53.968,
|
||||||
|
Power: 4.667,
|
||||||
|
Count: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing temperature",
|
||||||
|
input: "11-14-2024 22:54:33 RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
||||||
|
wantMetrics: &system.GPUData{
|
||||||
|
Name: "GPU",
|
||||||
MemoryUsed: 4300.0,
|
MemoryUsed: 4300.0,
|
||||||
MemoryTotal: 30698.0,
|
MemoryTotal: 30698.0,
|
||||||
Usage: 45.0,
|
Usage: 45.0,
|
||||||
@@ -230,32 +291,18 @@ func TestParseJetsonData(t *testing.T) {
|
|||||||
Count: 1,
|
Count: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "no gpu defined by nvidia-smi",
|
|
||||||
input: "RAM 4300/30698MB GR3D_FREQ 45% VDD_GPU_SOC 2171mW",
|
|
||||||
gm: &GPUManager{
|
|
||||||
GpuDataMap: map[string]*system.GPUData{},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
if tt.gm != nil {
|
gm := &GPUManager{
|
||||||
// should return if no gpu set by nvidia-smi
|
GpuDataMap: make(map[string]*system.GPUData),
|
||||||
assert.Empty(t, tt.gm.GpuDataMap)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
tt.gm = &GPUManager{
|
parser := gm.getJetsonParser()
|
||||||
GpuDataMap: map[string]*system.GPUData{
|
|
||||||
"0": {Name: "Jetson"},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
parser := tt.gm.getJetsonParser()
|
|
||||||
valid := parser([]byte(tt.input))
|
valid := parser([]byte(tt.input))
|
||||||
assert.Equal(t, true, valid)
|
assert.Equal(t, true, valid)
|
||||||
|
|
||||||
got := tt.gm.GpuDataMap["0"]
|
got := gm.GpuDataMap["0"]
|
||||||
require.NotNil(t, got)
|
require.NotNil(t, got)
|
||||||
assert.Equal(t, tt.wantMetrics.Name, got.Name)
|
assert.Equal(t, tt.wantMetrics.Name, got.Name)
|
||||||
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
|
assert.InDelta(t, tt.wantMetrics.MemoryUsed, got.MemoryUsed, 0.01)
|
||||||
@@ -381,7 +428,7 @@ echo "test"`
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
wantNvidiaSmi: true,
|
wantNvidiaSmi: false,
|
||||||
wantRocmSmi: true,
|
wantRocmSmi: true,
|
||||||
wantTegrastats: true,
|
wantTegrastats: true,
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
@@ -486,7 +533,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 +570,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{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
beszel/internal/agent/sensors.go
Normal file
143
beszel/internal/agent/sensors.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
|
"github.com/shirou/gopsutil/v4/sensors"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SensorConfig struct {
|
||||||
|
context context.Context
|
||||||
|
sensors map[string]struct{}
|
||||||
|
primarySensor string
|
||||||
|
isBlacklist bool
|
||||||
|
hasWildcards bool
|
||||||
|
skipCollection bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||||
|
primarySensor, _ := GetEnv("PRIMARY_SENSOR")
|
||||||
|
sysSensors, _ := GetEnv("SYS_SENSORS")
|
||||||
|
sensorsEnvVal, sensorsSet := GetEnv("SENSORS")
|
||||||
|
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||||
|
|
||||||
|
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
|
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||||
|
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
||||||
|
config := &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: primarySensor,
|
||||||
|
skipCollection: skipCollection,
|
||||||
|
sensors: make(map[string]struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sensors context (allows overriding sys location for sensors)
|
||||||
|
if sysSensors != "" {
|
||||||
|
slog.Info("SYS_SENSORS", "path", sysSensors)
|
||||||
|
config.context = context.WithValue(config.context,
|
||||||
|
common.EnvKey, common.EnvMap{common.HostSysEnvKey: sysSensors},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle blacklist
|
||||||
|
if strings.HasPrefix(sensorsEnvVal, "-") {
|
||||||
|
config.isBlacklist = true
|
||||||
|
sensorsEnvVal = sensorsEnvVal[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
for sensor := range strings.SplitSeq(sensorsEnvVal, ",") {
|
||||||
|
sensor = strings.TrimSpace(sensor)
|
||||||
|
if sensor != "" {
|
||||||
|
config.sensors[sensor] = struct{}{}
|
||||||
|
if strings.Contains(sensor, "*") {
|
||||||
|
config.hasWildcards = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateTemperatures updates the agent with the latest sensor temperatures
|
||||||
|
func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
||||||
|
// skip if sensors whitelist is set to empty string
|
||||||
|
if a.sensorConfig.skipCollection {
|
||||||
|
slog.Debug("Skipping temperature collection")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// reset high temp
|
||||||
|
a.systemInfo.DashboardTemp = 0
|
||||||
|
|
||||||
|
// get sensor data
|
||||||
|
temps, _ := sensors.TemperaturesWithContext(a.sensorConfig.context)
|
||||||
|
slog.Debug("Temperature", "sensors", temps)
|
||||||
|
|
||||||
|
// return if no sensors
|
||||||
|
if len(temps) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
|
for i, sensor := range temps {
|
||||||
|
// skip if temperature is unreasonable
|
||||||
|
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sensorName := sensor.SensorKey
|
||||||
|
if _, ok := systemStats.Temperatures[sensorName]; ok {
|
||||||
|
// if key already exists, append int to key
|
||||||
|
sensorName = sensorName + "_" + strconv.Itoa(i)
|
||||||
|
}
|
||||||
|
// skip if not in whitelist or blacklist
|
||||||
|
if !isValidSensor(sensorName, a.sensorConfig) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// set dashboard temperature
|
||||||
|
if a.sensorConfig.primarySensor == "" {
|
||||||
|
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, sensor.Temperature)
|
||||||
|
} else if a.sensorConfig.primarySensor == sensorName {
|
||||||
|
a.systemInfo.DashboardTemp = sensor.Temperature
|
||||||
|
}
|
||||||
|
systemStats.Temperatures[sensorName] = twoDecimals(sensor.Temperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
|
||||||
|
func isValidSensor(sensorName string, config *SensorConfig) bool {
|
||||||
|
// if no sensors configured, everything is valid
|
||||||
|
if len(config.sensors) == 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exact match - return true if whitelist, false if blacklist
|
||||||
|
if _, exactMatch := config.sensors[sensorName]; exactMatch {
|
||||||
|
return !config.isBlacklist
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no wildcards, return true if blacklist, false if whitelist
|
||||||
|
if !config.hasWildcards {
|
||||||
|
return config.isBlacklist
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for wildcard patterns
|
||||||
|
for pattern := range config.sensors {
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if match, _ := path.Match(pattern, sensorName); match {
|
||||||
|
return !config.isBlacklist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.isBlacklist
|
||||||
|
}
|
||||||
374
beszel/internal/agent/sensors_test.go
Normal file
374
beszel/internal/agent/sensors_test.go
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v4/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsValidSensor(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sensorName string
|
||||||
|
config *SensorConfig
|
||||||
|
expectedValid bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Whitelist - sensor in list",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist - sensor not in list",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist - sensor in list",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist - sensor not in list",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist with wildcard - matching pattern",
|
||||||
|
sensorName: "core_0_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist with wildcard - non-matching pattern",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist with wildcard - matching pattern",
|
||||||
|
sensorName: "core_0_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist with wildcard - non-matching pattern",
|
||||||
|
sensorName: "gpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No sensors configured",
|
||||||
|
sensorName: "any_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
skipCollection: false,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in whitelist - exact match",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in whitelist - wildcard match",
|
||||||
|
sensorName: "core_1_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in blacklist - exact match",
|
||||||
|
sensorName: "cpu_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed patterns in blacklist - wildcard match",
|
||||||
|
sensorName: "core_1_temp",
|
||||||
|
config: &SensorConfig{
|
||||||
|
sensors: map[string]struct{}{"cpu_temp": {}, "core_*_temp": {}},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
expectedValid: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isValidSensor(tt.sensorName, tt.config)
|
||||||
|
assert.Equal(t, tt.expectedValid, result, "isValidSensor(%q, config) returned unexpected result", tt.sensorName)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||||
|
agent := &Agent{}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
primarySensor string
|
||||||
|
sysSensors string
|
||||||
|
sensors string
|
||||||
|
skipCollection bool
|
||||||
|
expectedConfig *SensorConfig
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Empty configuration",
|
||||||
|
primarySensor: "",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "",
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
skipCollection: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Explicitly set to empty string",
|
||||||
|
primarySensor: "",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
skipCollection: true,
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "",
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
skipCollection: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Primary sensor only - should create sensor map",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitelist sensors",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "cpu_temp,gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_temp": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Blacklist sensors",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "-cpu_temp,gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_temp": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: true,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sensors with wildcard",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "cpu_*,gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_*": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Sensors with whitespace",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "",
|
||||||
|
sensors: "cpu_*, gpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
context: context.Background(),
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_*": {},
|
||||||
|
"gpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "With SYS_SENSORS path",
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sysSensors: "/custom/path",
|
||||||
|
sensors: "cpu_temp",
|
||||||
|
expectedConfig: &SensorConfig{
|
||||||
|
primarySensor: "cpu_temp",
|
||||||
|
sensors: map[string]struct{}{
|
||||||
|
"cpu_temp": {},
|
||||||
|
},
|
||||||
|
isBlacklist: false,
|
||||||
|
hasWildcards: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
|
||||||
|
|
||||||
|
// Check primary sensor
|
||||||
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
|
|
||||||
|
// Check sensor map
|
||||||
|
if tt.expectedConfig.sensors == nil {
|
||||||
|
assert.Nil(t, result.sensors)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, len(tt.expectedConfig.sensors), len(result.sensors))
|
||||||
|
for sensor := range tt.expectedConfig.sensors {
|
||||||
|
_, exists := result.sensors[sensor]
|
||||||
|
assert.True(t, exists, "Sensor %s should exist in the result", sensor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check flags
|
||||||
|
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
||||||
|
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
||||||
|
|
||||||
|
// Check context
|
||||||
|
if tt.sysSensors != "" {
|
||||||
|
// Verify context contains correct values
|
||||||
|
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
|
||||||
|
require.True(t, ok, "Context should contain EnvMap")
|
||||||
|
sysPath, ok := envMap[common.HostSysEnvKey]
|
||||||
|
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
||||||
|
assert.Equal(t, tt.sysSensors, sysPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewSensorConfig(t *testing.T) {
|
||||||
|
// Save original environment variables
|
||||||
|
originalPrimary, hasPrimary := os.LookupEnv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||||
|
originalSys, hasSys := os.LookupEnv("BESZEL_AGENT_SYS_SENSORS")
|
||||||
|
originalSensors, hasSensors := os.LookupEnv("BESZEL_AGENT_SENSORS")
|
||||||
|
|
||||||
|
// Restore environment variables after the test
|
||||||
|
defer func() {
|
||||||
|
// Clean up test environment variables
|
||||||
|
os.Unsetenv("BESZEL_AGENT_PRIMARY_SENSOR")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SYS_SENSORS")
|
||||||
|
os.Unsetenv("BESZEL_AGENT_SENSORS")
|
||||||
|
|
||||||
|
// Restore original values if they existed
|
||||||
|
if hasPrimary {
|
||||||
|
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", originalPrimary)
|
||||||
|
}
|
||||||
|
if hasSys {
|
||||||
|
os.Setenv("BESZEL_AGENT_SYS_SENSORS", originalSys)
|
||||||
|
}
|
||||||
|
if hasSensors {
|
||||||
|
os.Setenv("BESZEL_AGENT_SENSORS", originalSensors)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Set test environment variables
|
||||||
|
os.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||||
|
os.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||||
|
os.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||||
|
|
||||||
|
agent := &Agent{}
|
||||||
|
result := agent.newSensorConfig()
|
||||||
|
|
||||||
|
// Verify results
|
||||||
|
assert.Equal(t, "test_primary", result.primarySensor)
|
||||||
|
assert.NotNil(t, result.sensors)
|
||||||
|
assert.Equal(t, 3, len(result.sensors))
|
||||||
|
assert.True(t, result.hasWildcards)
|
||||||
|
assert.False(t, result.isBlacklist)
|
||||||
|
|
||||||
|
// Check that sys sensors path is in context
|
||||||
|
envMap, ok := result.context.Value(common.EnvKey).(common.EnvMap)
|
||||||
|
require.True(t, ok, "Context should contain EnvMap")
|
||||||
|
sysPath, ok := envMap[common.HostSysEnvKey]
|
||||||
|
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
||||||
|
assert.Equal(t, "/test/path", sysPath)
|
||||||
|
}
|
||||||
@@ -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},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,14 +16,31 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/host"
|
"github.com/shirou/gopsutil/v4/host"
|
||||||
"github.com/shirou/gopsutil/v4/mem"
|
"github.com/shirou/gopsutil/v4/mem"
|
||||||
psutilNet "github.com/shirou/gopsutil/v4/net"
|
psutilNet "github.com/shirou/gopsutil/v4/net"
|
||||||
"github.com/shirou/gopsutil/v4/sensors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sets initial / non-changing values about the host system
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) initializeSystemInfo() {
|
func (a *Agent) initializeSystemInfo() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
a.systemInfo.Hostname, _ = os.Hostname()
|
a.systemInfo.Hostname, _ = os.Hostname()
|
||||||
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
|
||||||
|
platform, _, version, _ := host.PlatformInformation()
|
||||||
|
|
||||||
|
if platform == "darwin" {
|
||||||
|
a.systemInfo.KernelVersion = version
|
||||||
|
a.systemInfo.Os = system.Darwin
|
||||||
|
} else if strings.Contains(platform, "indows") {
|
||||||
|
a.systemInfo.KernelVersion = strings.Replace(platform, "Microsoft ", "", 1) + " " + version
|
||||||
|
a.systemInfo.Os = system.Windows
|
||||||
|
} else if platform == "freebsd" {
|
||||||
|
a.systemInfo.Os = system.Freebsd
|
||||||
|
a.systemInfo.KernelVersion = version
|
||||||
|
} else {
|
||||||
|
a.systemInfo.Os = system.Linux
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.systemInfo.KernelVersion == "" {
|
||||||
|
a.systemInfo.KernelVersion, _ = host.KernelVersion()
|
||||||
|
}
|
||||||
|
|
||||||
// cpu model
|
// cpu model
|
||||||
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
if info, err := cpu.Info(); err == nil && len(info) > 0 {
|
||||||
@@ -184,11 +201,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 {
|
||||||
@@ -202,13 +217,24 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
if systemStats.Temperatures == nil {
|
if systemStats.Temperatures == nil {
|
||||||
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
systemStats.Temperatures = make(map[string]float64, len(gpuData))
|
||||||
}
|
}
|
||||||
|
highestTemp := 0.0
|
||||||
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.sensorConfig.primarySensor == gpu.Name {
|
||||||
|
a.systemInfo.DashboardTemp = gpu.Temperature
|
||||||
|
}
|
||||||
|
if gpu.Temperature > highestTemp {
|
||||||
|
highestTemp = 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)
|
||||||
}
|
}
|
||||||
|
// use highest temp for dashboard temp if dashboard temp is unset
|
||||||
|
if a.systemInfo.DashboardTemp == 0 {
|
||||||
|
a.systemInfo.DashboardTemp = highestTemp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,60 +249,6 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
return systemStats
|
return systemStats
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) updateTemperatures(systemStats *system.Stats) error {
|
|
||||||
// skip if sensors whitelist is set to empty string
|
|
||||||
if a.sensorsWhitelist != nil && len(a.sensorsWhitelist) == 0 {
|
|
||||||
slog.Debug("Skipping temperature collection")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
primarySensor, primarySensorIsDefined := GetEnv("PRIMARY_SENSOR")
|
|
||||||
|
|
||||||
// reset high temp
|
|
||||||
a.systemInfo.DashboardTemp = 0
|
|
||||||
|
|
||||||
// get sensor data
|
|
||||||
temps, err := sensors.TemperaturesWithContext(a.sensorsContext)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
slog.Debug("Temperature", "sensors", temps)
|
|
||||||
|
|
||||||
// return if no sensors
|
|
||||||
if len(temps) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
|
||||||
for i, sensor := range temps {
|
|
||||||
// skip if temperature is unreasonable
|
|
||||||
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
sensorName := sensor.SensorKey
|
|
||||||
if _, ok := systemStats.Temperatures[sensorName]; ok {
|
|
||||||
// if key already exists, append int to key
|
|
||||||
sensorName = sensorName + "_" + strconv.Itoa(i)
|
|
||||||
}
|
|
||||||
// skip if not in whitelist
|
|
||||||
if a.sensorsWhitelist != nil {
|
|
||||||
if _, nameInWhitelist := a.sensorsWhitelist[sensorName]; !nameInWhitelist {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// set dashboard temperature
|
|
||||||
if primarySensorIsDefined {
|
|
||||||
if sensorName == primarySensor {
|
|
||||||
a.systemInfo.DashboardTemp = sensor.Temperature
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
a.systemInfo.DashboardTemp = max(a.systemInfo.DashboardTemp, 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
|
||||||
func getARCSize() (uint64, error) {
|
func getARCSize() (uint64, error) {
|
||||||
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
file, err := os.Open("/proc/spl/kstat/zfs/arcstats")
|
||||||
|
|||||||
@@ -2,25 +2,24 @@
|
|||||||
package alerts
|
package alerts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/containrrr/shoutrrr"
|
"github.com/nicholas-fedor/shoutrrr"
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
"github.com/pocketbase/pocketbase/tools/types"
|
|
||||||
"github.com/spf13/cast"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
app core.App
|
app core.App
|
||||||
|
alertQueue chan alertTask
|
||||||
|
stopChan chan struct{}
|
||||||
|
pendingAlerts sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
type AlertMessageData struct {
|
type AlertMessageData struct {
|
||||||
@@ -60,350 +59,44 @@ type SystemAlertData struct {
|
|||||||
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
|
descriptor string // override descriptor in notification body (for temp sensor, disk partition, etc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// notification services that support title param
|
||||||
|
var supportsTitle = map[string]struct{}{
|
||||||
|
"bark": {},
|
||||||
|
"discord": {},
|
||||||
|
"gotify": {},
|
||||||
|
"ifttt": {},
|
||||||
|
"join": {},
|
||||||
|
"lark": {},
|
||||||
|
"matrix": {},
|
||||||
|
"ntfy": {},
|
||||||
|
"opsgenie": {},
|
||||||
|
"pushbullet": {},
|
||||||
|
"pushover": {},
|
||||||
|
"slack": {},
|
||||||
|
"teams": {},
|
||||||
|
"telegram": {},
|
||||||
|
"zulip": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAlertManager creates a new AlertManager instance.
|
||||||
func NewAlertManager(app core.App) *AlertManager {
|
func NewAlertManager(app core.App) *AlertManager {
|
||||||
return &AlertManager{
|
am := &AlertManager{
|
||||||
app: app,
|
app: app,
|
||||||
|
alertQueue: make(chan alertTask),
|
||||||
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
go am.startWorker()
|
||||||
|
return am
|
||||||
}
|
}
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, systemInfo system.Info, temperatures map[string]float64, extraFs map[string]*system.FsStats) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// start := time.Now()
|
|
||||||
// defer func() {
|
|
||||||
// log.Println("alert stats took", time.Since(start))
|
|
||||||
// }()
|
|
||||||
alertRecords, err := am.app.FindAllRecords("alerts",
|
|
||||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
|
||||||
)
|
|
||||||
if err != nil || len(alertRecords) == 0 {
|
|
||||||
// log.Println("no alerts found for system")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var validAlerts []SystemAlertData
|
|
||||||
now := systemRecord.GetDateTime("updated").Time().UTC()
|
|
||||||
oldestTime := now
|
|
||||||
|
|
||||||
for _, alertRecord := range alertRecords {
|
|
||||||
name := alertRecord.GetString("name")
|
|
||||||
var val float64
|
|
||||||
unit := "%"
|
|
||||||
|
|
||||||
switch name {
|
|
||||||
case "CPU":
|
|
||||||
val = systemInfo.Cpu
|
|
||||||
case "Memory":
|
|
||||||
val = systemInfo.MemPct
|
|
||||||
case "Bandwidth":
|
|
||||||
val = systemInfo.Bandwidth
|
|
||||||
unit = " MB/s"
|
|
||||||
case "Disk":
|
|
||||||
maxUsedPct := systemInfo.DiskPct
|
|
||||||
for _, fs := range extraFs {
|
|
||||||
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
|
||||||
if usedPct > maxUsedPct {
|
|
||||||
maxUsedPct = usedPct
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val = maxUsedPct
|
|
||||||
case "Temperature":
|
|
||||||
if temperatures == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, temp := range temperatures {
|
|
||||||
if temp > val {
|
|
||||||
val = temp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
unit = "°C"
|
|
||||||
}
|
|
||||||
|
|
||||||
triggered := alertRecord.GetBool("triggered")
|
|
||||||
threshold := alertRecord.GetFloat("value")
|
|
||||||
|
|
||||||
// CONTINUE
|
|
||||||
// IF alert is not triggered and curValue is less than threshold
|
|
||||||
// OR alert is triggered and curValue is greater than threshold
|
|
||||||
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
|
||||||
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
|
||||||
systemRecord: systemRecord,
|
|
||||||
alertRecord: alertRecord,
|
|
||||||
name: name,
|
|
||||||
unit: unit,
|
|
||||||
val: val,
|
|
||||||
threshold: threshold,
|
|
||||||
triggered: triggered,
|
|
||||||
time: time,
|
|
||||||
min: min,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
systemStats := []struct {
|
|
||||||
Stats []byte `db:"stats"`
|
|
||||||
Created types.DateTime `db:"created"`
|
|
||||||
}{}
|
|
||||||
|
|
||||||
err = am.app.DB().
|
|
||||||
Select("stats", "created").
|
|
||||||
From("system_stats").
|
|
||||||
Where(dbx.NewExp(
|
|
||||||
"system={:system} AND type='1m' AND created > {:created}",
|
|
||||||
dbx.Params{
|
|
||||||
"system": systemRecord.Id,
|
|
||||||
// subtract some time to give us a bit of buffer
|
|
||||||
"created": oldestTime.Add(-time.Second * 90),
|
|
||||||
},
|
|
||||||
)).
|
|
||||||
OrderBy("created").
|
|
||||||
All(&systemStats)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// get oldest record creation time from first record in the slice
|
|
||||||
oldestRecordTime := systemStats[0].Created.Time()
|
|
||||||
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
|
||||||
|
|
||||||
// delete from validAlerts if time is older than oldestRecord
|
|
||||||
for i := 0; i < len(validAlerts); i++ {
|
|
||||||
if validAlerts[i].time.Before(oldestRecordTime) {
|
|
||||||
// log.Println("deleting alert - time is older than oldestRecord", validAlerts[i].name, oldestRecordTime, validAlerts[i].time)
|
|
||||||
validAlerts = append(validAlerts[:i], validAlerts[i+1:]...)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(validAlerts) == 0 {
|
|
||||||
// log.Println("no valid alerts found")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var stats SystemAlertStats
|
|
||||||
|
|
||||||
// we can skip the latest systemStats record since it's the current value
|
|
||||||
for i := 0; i < len(systemStats); i++ {
|
|
||||||
stat := systemStats[i]
|
|
||||||
// subtract 10 seconds to give a small time buffer
|
|
||||||
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
|
||||||
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// log.Println("stats", stats)
|
|
||||||
for j := range validAlerts {
|
|
||||||
alert := &validAlerts[j]
|
|
||||||
// reset alert val on first iteration
|
|
||||||
if i == 0 {
|
|
||||||
alert.val = 0
|
|
||||||
}
|
|
||||||
// continue if system_stats is older than alert time range
|
|
||||||
if systemStatsCreation.Before(alert.time) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// add to alert value
|
|
||||||
switch alert.name {
|
|
||||||
case "CPU":
|
|
||||||
alert.val += stats.Cpu
|
|
||||||
case "Memory":
|
|
||||||
alert.val += stats.Mem
|
|
||||||
case "Bandwidth":
|
|
||||||
alert.val += stats.NetSent + stats.NetRecv
|
|
||||||
case "Disk":
|
|
||||||
if alert.mapSums == nil {
|
|
||||||
alert.mapSums = make(map[string]float32, len(extraFs)+1)
|
|
||||||
}
|
|
||||||
// add root disk
|
|
||||||
if _, ok := alert.mapSums["root"]; !ok {
|
|
||||||
alert.mapSums["root"] = 0.0
|
|
||||||
}
|
|
||||||
alert.mapSums["root"] += float32(stats.Disk)
|
|
||||||
// add extra disks
|
|
||||||
for key, fs := range extraFs {
|
|
||||||
if _, ok := alert.mapSums[key]; !ok {
|
|
||||||
alert.mapSums[key] = 0.0
|
|
||||||
}
|
|
||||||
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
|
||||||
}
|
|
||||||
case "Temperature":
|
|
||||||
if alert.mapSums == nil {
|
|
||||||
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
|
||||||
}
|
|
||||||
for key, temp := range stats.Temperatures {
|
|
||||||
if _, ok := alert.mapSums[key]; !ok {
|
|
||||||
alert.mapSums[key] = float32(0)
|
|
||||||
}
|
|
||||||
alert.mapSums[key] += temp
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
alert.count++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// sum up vals for each alert
|
|
||||||
for _, alert := range validAlerts {
|
|
||||||
switch alert.name {
|
|
||||||
case "Disk":
|
|
||||||
maxPct := float32(0)
|
|
||||||
for key, value := range alert.mapSums {
|
|
||||||
sumPct := float32(value)
|
|
||||||
if sumPct > maxPct {
|
|
||||||
maxPct = sumPct
|
|
||||||
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val = float64(maxPct / float32(alert.count))
|
|
||||||
case "Temperature":
|
|
||||||
maxTemp := float32(0)
|
|
||||||
for key, value := range alert.mapSums {
|
|
||||||
sumTemp := float32(value) / float32(alert.count)
|
|
||||||
if sumTemp > maxTemp {
|
|
||||||
maxTemp = sumTemp
|
|
||||||
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
alert.val = float64(maxTemp)
|
|
||||||
default:
|
|
||||||
alert.val = alert.val / float64(alert.count)
|
|
||||||
}
|
|
||||||
minCount := float32(alert.min) / 1.2
|
|
||||||
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
|
||||||
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
|
||||||
// pass through alert if count is greater than or equal to minCount
|
|
||||||
if float32(alert.count) >= minCount {
|
|
||||||
if !alert.triggered && alert.val > alert.threshold {
|
|
||||||
alert.triggered = true
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
} else if alert.triggered && alert.val <= alert.threshold {
|
|
||||||
alert.triggered = false
|
|
||||||
go am.sendSystemAlert(alert)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|
||||||
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
|
||||||
systemName := alert.systemRecord.GetString("name")
|
|
||||||
|
|
||||||
// change Disk to Disk usage
|
|
||||||
if alert.name == "Disk" {
|
|
||||||
alert.name += " usage"
|
|
||||||
}
|
|
||||||
|
|
||||||
// make title alert name lowercase if not CPU
|
|
||||||
titleAlertName := alert.name
|
|
||||||
if titleAlertName != "CPU" {
|
|
||||||
titleAlertName = strings.ToLower(titleAlertName)
|
|
||||||
}
|
|
||||||
|
|
||||||
var subject string
|
|
||||||
if alert.triggered {
|
|
||||||
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
|
||||||
} else {
|
|
||||||
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
|
||||||
}
|
|
||||||
minutesLabel := "minute"
|
|
||||||
if alert.min > 1 {
|
|
||||||
minutesLabel += "s"
|
|
||||||
}
|
|
||||||
if alert.descriptor == "" {
|
|
||||||
alert.descriptor = alert.name
|
|
||||||
}
|
|
||||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
|
||||||
|
|
||||||
alert.alertRecord.Set("triggered", alert.triggered)
|
|
||||||
if err := am.app.Save(alert.alertRecord); err != nil {
|
|
||||||
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// expand the user relation and send the alert
|
|
||||||
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
|
||||||
am.sendAlert(AlertMessageData{
|
|
||||||
UserID: user.Id,
|
|
||||||
Title: subject,
|
|
||||||
Message: body,
|
|
||||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// todo: allow x minutes downtime before sending alert
|
|
||||||
func (am *AlertManager) HandleStatusAlerts(newStatus string, oldSystemRecord *core.Record) error {
|
|
||||||
var alertStatus string
|
|
||||||
switch newStatus {
|
|
||||||
case "up":
|
|
||||||
if oldSystemRecord.GetString("status") == "down" {
|
|
||||||
alertStatus = "up"
|
|
||||||
}
|
|
||||||
case "down":
|
|
||||||
if oldSystemRecord.GetString("status") == "up" {
|
|
||||||
alertStatus = "down"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if alertStatus == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
// check if use
|
|
||||||
alertRecords, err := am.app.FindAllRecords("alerts",
|
|
||||||
dbx.HashExp{
|
|
||||||
"system": oldSystemRecord.Id,
|
|
||||||
"name": "Status",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
if err != nil || len(alertRecords) == 0 {
|
|
||||||
// log.Println("no alerts found for system")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
for _, alertRecord := range alertRecords {
|
|
||||||
// expand the user relation
|
|
||||||
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
|
||||||
return fmt.Errorf("failed to expand: %v", errs)
|
|
||||||
}
|
|
||||||
user := alertRecord.ExpandedOne("user")
|
|
||||||
if user == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
emoji := "\U0001F534"
|
|
||||||
if alertStatus == "up" {
|
|
||||||
emoji = "\u2705"
|
|
||||||
}
|
|
||||||
// send alert
|
|
||||||
systemName := oldSystemRecord.GetString("name")
|
|
||||||
am.sendAlert(AlertMessageData{
|
|
||||||
UserID: user.Id,
|
|
||||||
Title: fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji),
|
|
||||||
Message: fmt.Sprintf("Connection to %s is %s", systemName, alertStatus),
|
|
||||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
|
||||||
LinkText: "View " + systemName,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) sendAlert(data AlertMessageData) {
|
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.app.FindFirstRecordByFilter(
|
record, err := am.app.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
dbx.Params{"user": data.UserID},
|
dbx.Params{"user": data.UserID},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
am.app.Logger().Error("Failed to get user settings", "err", err.Error())
|
return err
|
||||||
return
|
|
||||||
}
|
}
|
||||||
// unmarshal user settings
|
// unmarshal user settings
|
||||||
userAlertSettings := UserNotificationSettings{
|
userAlertSettings := UserNotificationSettings{
|
||||||
@@ -421,8 +114,7 @@ func (am *AlertManager) sendAlert(data AlertMessageData) {
|
|||||||
}
|
}
|
||||||
// send alerts via email
|
// send alerts via email
|
||||||
if len(userAlertSettings.Emails) == 0 {
|
if len(userAlertSettings.Emails) == 0 {
|
||||||
// log.Println("No email addresses found")
|
return nil
|
||||||
return
|
|
||||||
}
|
}
|
||||||
addresses := []mail.Address{}
|
addresses := []mail.Address{}
|
||||||
for _, email := range userAlertSettings.Emails {
|
for _, email := range userAlertSettings.Emails {
|
||||||
@@ -437,18 +129,16 @@ func (am *AlertManager) sendAlert(data AlertMessageData) {
|
|||||||
Name: am.app.Settings().Meta.SenderName,
|
Name: am.app.Settings().Meta.SenderName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
if err := am.app.NewMailClient().Send(&message); err != nil {
|
err = am.app.NewMailClient().Send(&message)
|
||||||
am.app.Logger().Error("Failed to send alert: ", "err", err.Error())
|
if err != nil {
|
||||||
} else {
|
return err
|
||||||
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
|
||||||
}
|
}
|
||||||
|
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
// SendShoutrrrAlert sends an alert via a Shoutrrr URL
|
||||||
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link, linkText string) error {
|
||||||
// services that support title param
|
|
||||||
supportsTitle := []string{"bark", "discord", "gotify", "ifttt", "join", "matrix", "ntfy", "opsgenie", "pushbullet", "pushover", "slack", "teams", "telegram", "zulip"}
|
|
||||||
|
|
||||||
// Parse the URL
|
// Parse the URL
|
||||||
parsedURL, err := url.Parse(notificationUrl)
|
parsedURL, err := url.Parse(notificationUrl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -458,7 +148,7 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
queryParams := parsedURL.Query()
|
queryParams := parsedURL.Query()
|
||||||
|
|
||||||
// Add title
|
// Add title
|
||||||
if sliceContains(supportsTitle, scheme) {
|
if _, ok := supportsTitle[scheme]; ok {
|
||||||
queryParams.Add("title", title)
|
queryParams.Add("title", title)
|
||||||
} else if scheme == "mattermost" {
|
} else if scheme == "mattermost" {
|
||||||
// use markdown title for mattermost
|
// use markdown title for mattermost
|
||||||
@@ -477,10 +167,12 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
|
|
||||||
// Add link
|
// Add link
|
||||||
if scheme == "ntfy" {
|
if scheme == "ntfy" {
|
||||||
// if ntfy, add link to actions
|
|
||||||
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
|
queryParams.Add("Actions", fmt.Sprintf("view, %s, %s", linkText, link))
|
||||||
|
} else if scheme == "lark" {
|
||||||
|
queryParams.Add("link", link)
|
||||||
|
} else if scheme == "bark" {
|
||||||
|
queryParams.Add("url", link)
|
||||||
} else {
|
} else {
|
||||||
// else add link directly to the message
|
|
||||||
message += "\n\n" + link
|
message += "\n\n" + link
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,16 +191,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contains checks if a string is present in a slice of strings
|
|
||||||
func sliceContains(slice []string, item string) bool {
|
|
||||||
for _, v := range slice {
|
|
||||||
if v == item {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||||
info, _ := e.RequestInfo()
|
info, _ := e.RequestInfo()
|
||||||
if info.Auth == nil {
|
if info.Auth == nil {
|
||||||
|
|||||||
165
beszel/internal/alerts/alerts_status.go
Normal file
165
beszel/internal/alerts/alerts_status.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
|
type alertTask struct {
|
||||||
|
action string // "schedule" or "cancel"
|
||||||
|
systemName string
|
||||||
|
alertRecord *core.Record
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertInfo struct {
|
||||||
|
systemName string
|
||||||
|
alertRecord *core.Record
|
||||||
|
expireTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWorker is a long-running goroutine that processes alert tasks
|
||||||
|
// every x seconds. It must be running to process status alerts.
|
||||||
|
func (am *AlertManager) startWorker() {
|
||||||
|
tick := time.Tick(15 * time.Second)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-am.stopChan:
|
||||||
|
return
|
||||||
|
case task := <-am.alertQueue:
|
||||||
|
switch task.action {
|
||||||
|
case "schedule":
|
||||||
|
am.pendingAlerts.Store(task.alertRecord.Id, &alertInfo{
|
||||||
|
systemName: task.systemName,
|
||||||
|
alertRecord: task.alertRecord,
|
||||||
|
expireTime: time.Now().Add(task.delay),
|
||||||
|
})
|
||||||
|
case "cancel":
|
||||||
|
am.pendingAlerts.Delete(task.alertRecord.Id)
|
||||||
|
}
|
||||||
|
case <-tick:
|
||||||
|
// Check for expired alerts every tick
|
||||||
|
now := time.Now()
|
||||||
|
for key, value := range am.pendingAlerts.Range {
|
||||||
|
info := value.(*alertInfo)
|
||||||
|
if now.After(info.expireTime) {
|
||||||
|
// Downtime delay has passed, process alert
|
||||||
|
am.sendStatusAlert("down", info.systemName, info.alertRecord)
|
||||||
|
am.pendingAlerts.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopWorker shuts down the AlertManager.worker goroutine
|
||||||
|
func (am *AlertManager) StopWorker() {
|
||||||
|
close(am.stopChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleStatusAlerts manages the logic when system status changes.
|
||||||
|
func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.Record) error {
|
||||||
|
if newStatus != "up" && newStatus != "down" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
alertRecords, err := am.getSystemStatusAlerts(systemRecord.Id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(alertRecords) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
systemName := systemRecord.GetString("name")
|
||||||
|
if newStatus == "down" {
|
||||||
|
am.handleSystemDown(systemName, alertRecords)
|
||||||
|
} else {
|
||||||
|
am.handleSystemUp(systemName, alertRecords)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
||||||
|
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
||||||
|
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
|
||||||
|
"system": systemID,
|
||||||
|
"name": "Status",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return alertRecords, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedules delayed "down" alerts for each alert record.
|
||||||
|
func (am *AlertManager) handleSystemDown(systemName string, alertRecords []*core.Record) {
|
||||||
|
for _, alertRecord := range alertRecords {
|
||||||
|
// Continue if alert is already scheduled
|
||||||
|
if _, exists := am.pendingAlerts.Load(alertRecord.Id); exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Schedule by adding to queue
|
||||||
|
min := max(1, alertRecord.GetInt("min"))
|
||||||
|
am.alertQueue <- alertTask{
|
||||||
|
action: "schedule",
|
||||||
|
systemName: systemName,
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
delay: time.Duration(min) * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSystemUp manages the logic when a system status changes to "up".
|
||||||
|
// It cancels any pending alerts and sends "up" alerts.
|
||||||
|
func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.Record) {
|
||||||
|
for _, alertRecord := range alertRecords {
|
||||||
|
alertRecordID := alertRecord.Id
|
||||||
|
// If alert exists for record, delete and continue (down alert not sent)
|
||||||
|
if _, exists := am.pendingAlerts.Load(alertRecordID); exists {
|
||||||
|
am.alertQueue <- alertTask{
|
||||||
|
action: "cancel",
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No alert scheduled for this record, send "up" alert
|
||||||
|
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
||||||
|
am.app.Logger().Error("Failed to send alert", "err", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendStatusAlert sends a status alert ("up" or "down") to the users associated with the alert records.
|
||||||
|
func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, alertRecord *core.Record) error {
|
||||||
|
var emoji string
|
||||||
|
if alertStatus == "up" {
|
||||||
|
emoji = "\u2705" // Green checkmark emoji
|
||||||
|
} else {
|
||||||
|
emoji = "\U0001F534" // Red alert emoji
|
||||||
|
}
|
||||||
|
|
||||||
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
|
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
|
return errs["user"]
|
||||||
|
}
|
||||||
|
user := alertRecord.ExpandedOne("user")
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return am.SendAlert(AlertMessageData{
|
||||||
|
UserID: user.Id,
|
||||||
|
Title: title,
|
||||||
|
Message: message,
|
||||||
|
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
})
|
||||||
|
}
|
||||||
292
beszel/internal/alerts/alerts_system.go
Normal file
292
beszel/internal/alerts/alerts_system.go
Normal file
@@ -0,0 +1,292 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/pocketbase/dbx"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
"github.com/pocketbase/pocketbase/tools/types"
|
||||||
|
"github.com/spf13/cast"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||||
|
alertRecords, err := am.app.FindAllRecords("alerts",
|
||||||
|
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
||||||
|
)
|
||||||
|
if err != nil || len(alertRecords) == 0 {
|
||||||
|
// log.Println("no alerts found for system")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var validAlerts []SystemAlertData
|
||||||
|
now := systemRecord.GetDateTime("updated").Time().UTC()
|
||||||
|
oldestTime := now
|
||||||
|
|
||||||
|
for _, alertRecord := range alertRecords {
|
||||||
|
name := alertRecord.GetString("name")
|
||||||
|
var val float64
|
||||||
|
unit := "%"
|
||||||
|
|
||||||
|
switch name {
|
||||||
|
case "CPU":
|
||||||
|
val = data.Info.Cpu
|
||||||
|
case "Memory":
|
||||||
|
val = data.Info.MemPct
|
||||||
|
case "Bandwidth":
|
||||||
|
val = data.Info.Bandwidth
|
||||||
|
unit = " MB/s"
|
||||||
|
case "Disk":
|
||||||
|
maxUsedPct := data.Info.DiskPct
|
||||||
|
for _, fs := range data.Stats.ExtraFs {
|
||||||
|
usedPct := fs.DiskUsed / fs.DiskTotal * 100
|
||||||
|
if usedPct > maxUsedPct {
|
||||||
|
maxUsedPct = usedPct
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val = maxUsedPct
|
||||||
|
case "Temperature":
|
||||||
|
if data.Info.DashboardTemp < 1 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val = data.Info.DashboardTemp
|
||||||
|
unit = "°C"
|
||||||
|
}
|
||||||
|
|
||||||
|
triggered := alertRecord.GetBool("triggered")
|
||||||
|
threshold := alertRecord.GetFloat("value")
|
||||||
|
|
||||||
|
// CONTINUE
|
||||||
|
// IF alert is not triggered and curValue is less than threshold
|
||||||
|
// OR alert is triggered and curValue is greater than threshold
|
||||||
|
if (!triggered && val <= threshold) || (triggered && val > threshold) {
|
||||||
|
// log.Printf("Skipping alert %s: val %f | threshold %f | triggered %v\n", name, val, threshold, triggered)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
min := max(1, cast.ToUint8(alertRecord.Get("min")))
|
||||||
|
|
||||||
|
alert := SystemAlertData{
|
||||||
|
systemRecord: systemRecord,
|
||||||
|
alertRecord: alertRecord,
|
||||||
|
name: name,
|
||||||
|
unit: unit,
|
||||||
|
val: val,
|
||||||
|
threshold: threshold,
|
||||||
|
triggered: triggered,
|
||||||
|
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 {
|
||||||
|
Stats []byte `db:"stats"`
|
||||||
|
Created types.DateTime `db:"created"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
err = am.app.DB().
|
||||||
|
Select("stats", "created").
|
||||||
|
From("system_stats").
|
||||||
|
Where(dbx.NewExp(
|
||||||
|
"system={:system} AND type='1m' AND created > {:created}",
|
||||||
|
dbx.Params{
|
||||||
|
"system": systemRecord.Id,
|
||||||
|
// subtract some time to give us a bit of buffer
|
||||||
|
"created": oldestTime.Add(-time.Second * 90),
|
||||||
|
},
|
||||||
|
)).
|
||||||
|
OrderBy("created").
|
||||||
|
All(&systemStats)
|
||||||
|
if err != nil || len(systemStats) == 0 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// get oldest record creation time from first record in the slice
|
||||||
|
oldestRecordTime := systemStats[0].Created.Time()
|
||||||
|
// log.Println("oldestRecordTime", oldestRecordTime.String())
|
||||||
|
|
||||||
|
// Filter validAlerts to keep only those with time newer than oldestRecord
|
||||||
|
filteredAlerts := make([]SystemAlertData, 0, len(validAlerts))
|
||||||
|
for _, alert := range validAlerts {
|
||||||
|
if alert.time.After(oldestRecordTime) {
|
||||||
|
filteredAlerts = append(filteredAlerts, alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
validAlerts = filteredAlerts
|
||||||
|
|
||||||
|
if len(validAlerts) == 0 {
|
||||||
|
// log.Println("no valid alerts found")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var stats SystemAlertStats
|
||||||
|
|
||||||
|
// we can skip the latest systemStats record since it's the current value
|
||||||
|
for i := range systemStats {
|
||||||
|
stat := systemStats[i]
|
||||||
|
// subtract 10 seconds to give a small time buffer
|
||||||
|
systemStatsCreation := stat.Created.Time().Add(-time.Second * 10)
|
||||||
|
if err := json.Unmarshal(stat.Stats, &stats); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// log.Println("stats", stats)
|
||||||
|
for j := range validAlerts {
|
||||||
|
alert := &validAlerts[j]
|
||||||
|
// reset alert val on first iteration
|
||||||
|
if i == 0 {
|
||||||
|
alert.val = 0
|
||||||
|
}
|
||||||
|
// continue if system_stats is older than alert time range
|
||||||
|
if systemStatsCreation.Before(alert.time) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// add to alert value
|
||||||
|
switch alert.name {
|
||||||
|
case "CPU":
|
||||||
|
alert.val += stats.Cpu
|
||||||
|
case "Memory":
|
||||||
|
alert.val += stats.Mem
|
||||||
|
case "Bandwidth":
|
||||||
|
alert.val += stats.NetSent + stats.NetRecv
|
||||||
|
case "Disk":
|
||||||
|
if alert.mapSums == nil {
|
||||||
|
alert.mapSums = make(map[string]float32, len(data.Stats.ExtraFs)+1)
|
||||||
|
}
|
||||||
|
// add root disk
|
||||||
|
if _, ok := alert.mapSums["root"]; !ok {
|
||||||
|
alert.mapSums["root"] = 0.0
|
||||||
|
}
|
||||||
|
alert.mapSums["root"] += float32(stats.Disk)
|
||||||
|
// add extra disks
|
||||||
|
for key, fs := range data.Stats.ExtraFs {
|
||||||
|
if _, ok := alert.mapSums[key]; !ok {
|
||||||
|
alert.mapSums[key] = 0.0
|
||||||
|
}
|
||||||
|
alert.mapSums[key] += float32(fs.DiskUsed / fs.DiskTotal * 100)
|
||||||
|
}
|
||||||
|
case "Temperature":
|
||||||
|
if alert.mapSums == nil {
|
||||||
|
alert.mapSums = make(map[string]float32, len(stats.Temperatures))
|
||||||
|
}
|
||||||
|
for key, temp := range stats.Temperatures {
|
||||||
|
if _, ok := alert.mapSums[key]; !ok {
|
||||||
|
alert.mapSums[key] = float32(0)
|
||||||
|
}
|
||||||
|
alert.mapSums[key] += temp
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alert.count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// sum up vals for each alert
|
||||||
|
for _, alert := range validAlerts {
|
||||||
|
switch alert.name {
|
||||||
|
case "Disk":
|
||||||
|
maxPct := float32(0)
|
||||||
|
for key, value := range alert.mapSums {
|
||||||
|
sumPct := float32(value)
|
||||||
|
if sumPct > maxPct {
|
||||||
|
maxPct = sumPct
|
||||||
|
alert.descriptor = fmt.Sprintf("Usage of %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val = float64(maxPct / float32(alert.count))
|
||||||
|
case "Temperature":
|
||||||
|
maxTemp := float32(0)
|
||||||
|
for key, value := range alert.mapSums {
|
||||||
|
sumTemp := float32(value) / float32(alert.count)
|
||||||
|
if sumTemp > maxTemp {
|
||||||
|
maxTemp = sumTemp
|
||||||
|
alert.descriptor = fmt.Sprintf("Highest sensor %s", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alert.val = float64(maxTemp)
|
||||||
|
default:
|
||||||
|
alert.val = alert.val / float64(alert.count)
|
||||||
|
}
|
||||||
|
minCount := float32(alert.min) / 1.2
|
||||||
|
// log.Println("alert", alert.name, "val", alert.val, "threshold", alert.threshold, "triggered", alert.triggered)
|
||||||
|
// log.Printf("%s: val %f | count %d | min-count %f | threshold %f\n", alert.name, alert.val, alert.count, minCount, alert.threshold)
|
||||||
|
// pass through alert if count is greater than or equal to minCount
|
||||||
|
if float32(alert.count) >= minCount {
|
||||||
|
if !alert.triggered && alert.val > alert.threshold {
|
||||||
|
alert.triggered = true
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
} else if alert.triggered && alert.val <= alert.threshold {
|
||||||
|
alert.triggered = false
|
||||||
|
go am.sendSystemAlert(alert)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
||||||
|
// log.Printf("Sending alert %s: val %f | count %d | threshold %f\n", alert.name, alert.val, alert.count, alert.threshold)
|
||||||
|
systemName := alert.systemRecord.GetString("name")
|
||||||
|
|
||||||
|
// change Disk to Disk usage
|
||||||
|
if alert.name == "Disk" {
|
||||||
|
alert.name += " usage"
|
||||||
|
}
|
||||||
|
|
||||||
|
// make title alert name lowercase if not CPU
|
||||||
|
titleAlertName := alert.name
|
||||||
|
if titleAlertName != "CPU" {
|
||||||
|
titleAlertName = strings.ToLower(titleAlertName)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subject string
|
||||||
|
if alert.triggered {
|
||||||
|
subject = fmt.Sprintf("%s %s above threshold", systemName, titleAlertName)
|
||||||
|
} else {
|
||||||
|
subject = fmt.Sprintf("%s %s below threshold", systemName, titleAlertName)
|
||||||
|
}
|
||||||
|
minutesLabel := "minute"
|
||||||
|
if alert.min > 1 {
|
||||||
|
minutesLabel += "s"
|
||||||
|
}
|
||||||
|
if alert.descriptor == "" {
|
||||||
|
alert.descriptor = alert.name
|
||||||
|
}
|
||||||
|
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
||||||
|
|
||||||
|
alert.alertRecord.Set("triggered", alert.triggered)
|
||||||
|
if err := am.app.Save(alert.alertRecord); err != nil {
|
||||||
|
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// expand the user relation and send the alert
|
||||||
|
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user := alert.alertRecord.ExpandedOne("user"); user != nil {
|
||||||
|
am.SendAlert(AlertMessageData{
|
||||||
|
UserID: user.Id,
|
||||||
|
Title: subject,
|
||||||
|
Message: body,
|
||||||
|
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
||||||
|
LinkText: "View " + systemName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,38 +27,41 @@ type ApiInfo struct {
|
|||||||
|
|
||||||
// Docker container resources from /containers/{id}/stats
|
// Docker container resources from /containers/{id}/stats
|
||||||
type ApiStats struct {
|
type ApiStats struct {
|
||||||
// Common stats
|
Read time.Time `json:"read"` // Time of stats generation
|
||||||
// Read time.Time `json:"read"`
|
NumProcs uint32 `json:"num_procs,omitzero"` // Windows specific, not populated on Linux.
|
||||||
// PreRead time.Time `json:"preread"`
|
Networks map[string]NetworkStats
|
||||||
|
CPUStats CPUStats `json:"cpu_stats"`
|
||||||
|
MemoryStats MemoryStats `json:"memory_stats"`
|
||||||
|
}
|
||||||
|
|
||||||
// Linux specific stats, not populated on Windows.
|
func (s *ApiStats) CalculateCpuPercentLinux(prevCpuUsage [2]uint64) float64 {
|
||||||
// PidsStats PidsStats `json:"pids_stats,omitempty"`
|
cpuDelta := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage[0]
|
||||||
// BlkioStats BlkioStats `json:"blkio_stats,omitempty"`
|
systemDelta := s.CPUStats.SystemUsage - prevCpuUsage[1]
|
||||||
|
return float64(cpuDelta) / float64(systemDelta) * 100
|
||||||
|
}
|
||||||
|
|
||||||
// Windows specific stats, not populated on Linux.
|
// from: https://github.com/docker/cli/blob/master/cli/command/container/stats_helpers.go#L185
|
||||||
// NumProcs uint32 `json:"num_procs"`
|
func (s *ApiStats) CalculateCpuPercentWindows(prevCpuUsage uint64, prevRead time.Time) float64 {
|
||||||
// StorageStats StorageStats `json:"storage_stats,omitempty"`
|
// Max number of 100ns intervals between the previous time read and now
|
||||||
// Networks request version >=1.21
|
possIntervals := uint64(s.Read.Sub(prevRead).Nanoseconds())
|
||||||
Networks map[string]NetworkStats
|
possIntervals /= 100 // Convert to number of 100ns intervals
|
||||||
|
possIntervals *= uint64(s.NumProcs) // Multiple by the number of processors
|
||||||
|
|
||||||
// Shared stats
|
// Intervals used
|
||||||
CPUStats CPUStats `json:"cpu_stats,omitempty"`
|
intervalsUsed := s.CPUStats.CPUUsage.TotalUsage - prevCpuUsage
|
||||||
// PreCPUStats CPUStats `json:"precpu_stats,omitempty"` // "Pre"="Previous"
|
|
||||||
MemoryStats MemoryStats `json:"memory_stats,omitempty"`
|
// Percentage avoiding divide-by-zero
|
||||||
|
if possIntervals > 0 {
|
||||||
|
return float64(intervalsUsed) / float64(possIntervals) * 100.0
|
||||||
|
}
|
||||||
|
return 0.00
|
||||||
}
|
}
|
||||||
|
|
||||||
type CPUStats struct {
|
type CPUStats struct {
|
||||||
// CPU Usage. Linux and Windows.
|
// CPU Usage. Linux and Windows.
|
||||||
CPUUsage CPUUsage `json:"cpu_usage"`
|
CPUUsage CPUUsage `json:"cpu_usage"`
|
||||||
|
|
||||||
// System Usage. Linux only.
|
// System Usage. Linux only.
|
||||||
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
|
SystemUsage uint64 `json:"system_cpu_usage,omitempty"`
|
||||||
|
|
||||||
// Online CPUs. Linux only.
|
|
||||||
// OnlineCPUs uint32 `json:"online_cpus,omitempty"`
|
|
||||||
|
|
||||||
// Throttling Data. Linux only.
|
|
||||||
// ThrottlingData ThrottlingData `json:"throttling_data,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type CPUUsage struct {
|
type CPUUsage struct {
|
||||||
@@ -66,42 +69,15 @@ type CPUUsage struct {
|
|||||||
// Units: nanoseconds (Linux)
|
// Units: nanoseconds (Linux)
|
||||||
// Units: 100's of nanoseconds (Windows)
|
// Units: 100's of nanoseconds (Windows)
|
||||||
TotalUsage uint64 `json:"total_usage"`
|
TotalUsage uint64 `json:"total_usage"`
|
||||||
|
|
||||||
// Total CPU time consumed per core (Linux). Not used on Windows.
|
|
||||||
// Units: nanoseconds.
|
|
||||||
// PercpuUsage []uint64 `json:"percpu_usage,omitempty"`
|
|
||||||
|
|
||||||
// Time spent by tasks of the cgroup in kernel mode (Linux).
|
|
||||||
// Time spent by all container processes in kernel mode (Windows).
|
|
||||||
// Units: nanoseconds (Linux).
|
|
||||||
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers.
|
|
||||||
// UsageInKernelmode uint64 `json:"usage_in_kernelmode"`
|
|
||||||
|
|
||||||
// Time spent by tasks of the cgroup in user mode (Linux).
|
|
||||||
// Time spent by all container processes in user mode (Windows).
|
|
||||||
// Units: nanoseconds (Linux).
|
|
||||||
// Units: 100's of nanoseconds (Windows). Not populated for Hyper-V Containers
|
|
||||||
// UsageInUsermode uint64 `json:"usage_in_usermode"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryStats struct {
|
type MemoryStats struct {
|
||||||
// current res_counter usage for memory
|
// current res_counter usage for memory
|
||||||
Usage uint64 `json:"usage,omitempty"`
|
Usage uint64 `json:"usage,omitempty"`
|
||||||
// all the stats exported via memory.stat.
|
// all the stats exported via memory.stat.
|
||||||
Stats MemoryStatsStats `json:"stats,omitempty"`
|
Stats MemoryStatsStats `json:"stats"`
|
||||||
// maximum usage ever recorded.
|
// private working set (Windows only)
|
||||||
// MaxUsage uint64 `json:"max_usage,omitempty"`
|
PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
||||||
// TODO(vishh): Export these as stronger types.
|
|
||||||
// number of times memory usage hits limits.
|
|
||||||
// Failcnt uint64 `json:"failcnt,omitempty"`
|
|
||||||
// Limit uint64 `json:"limit,omitempty"`
|
|
||||||
|
|
||||||
// // committed bytes
|
|
||||||
// Commit uint64 `json:"commitbytes,omitempty"`
|
|
||||||
// // peak committed bytes
|
|
||||||
// CommitPeak uint64 `json:"commitpeakbytes,omitempty"`
|
|
||||||
// // private working set
|
|
||||||
// PrivateWorkingSet uint64 `json:"privateworkingset,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryStatsStats struct {
|
type MemoryStatsStats struct {
|
||||||
@@ -119,7 +95,6 @@ type NetworkStats struct {
|
|||||||
type prevNetStats struct {
|
type prevNetStats struct {
|
||||||
Sent uint64
|
Sent uint64
|
||||||
Recv uint64
|
Recv uint64
|
||||||
Time time.Time
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Docker container stats
|
// Docker container stats
|
||||||
@@ -131,4 +106,5 @@ type Stats struct {
|
|||||||
NetworkRecv float64 `json:"nr"`
|
NetworkRecv float64 `json:"nr"`
|
||||||
PrevCpu [2]uint64 `json:"-"`
|
PrevCpu [2]uint64 `json:"-"`
|
||||||
PrevNet prevNetStats `json:"-"`
|
PrevNet prevNetStats `json:"-"`
|
||||||
|
PrevRead time.Time `json:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -62,6 +64,15 @@ type NetIoStats struct {
|
|||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Os uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
Linux Os = iota
|
||||||
|
Darwin
|
||||||
|
Windows
|
||||||
|
Freebsd
|
||||||
|
)
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h"`
|
Hostname string `json:"h"`
|
||||||
KernelVersion string `json:"k,omitempty"`
|
KernelVersion string `json:"k,omitempty"`
|
||||||
@@ -77,6 +88,7 @@ type Info struct {
|
|||||||
Podman bool `json:"p,omitempty"`
|
Podman bool `json:"p,omitempty"`
|
||||||
GpuPct float64 `json:"g,omitempty"`
|
GpuPct float64 `json:"g,omitempty"`
|
||||||
DashboardTemp float64 `json:"dt,omitempty"`
|
DashboardTemp float64 `json:"dt,omitempty"`
|
||||||
|
Os Os `json:"os"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Final data structure to return to the hub
|
// Final data structure to return to the hub
|
||||||
|
|||||||
@@ -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",
|
||||||
@@ -33,12 +33,13 @@ const config: LinguiConfig = {
|
|||||||
],
|
],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
compileNamespace: "ts",
|
compileNamespace: "ts",
|
||||||
|
formatOptions: {
|
||||||
|
lineNumbers: false,
|
||||||
|
},
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
path: "<rootDir>/src/locales/{locale}/{locale}",
|
path: "<rootDir>/src/locales/{locale}/{locale}",
|
||||||
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.11.1",
|
||||||
"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": {
|
||||||
@@ -69,4 +70,4 @@
|
|||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@esbuild/linux-arm64": "^0.21.5"
|
"@esbuild/linux-arm64": "^0.21.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,15 +16,15 @@ 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, ExternalLinkIcon, PlusIcon } from "lucide-react"
|
||||||
import { memo, useRef, useState } from "react"
|
import { memo, useRef, useState } from "react"
|
||||||
import { basePath, navigate } from "./router"
|
import { basePath, navigate } from "./router"
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "./ui/dropdown-menu"
|
||||||
import { SystemRecord } from "@/types"
|
import { SystemRecord } from "@/types"
|
||||||
|
import { AppleIcon, DockerIcon, TuxIcon, WindowsIcon } from "./ui/icons"
|
||||||
|
|
||||||
export function AddSystemButton({ className }: { className?: string }) {
|
export function AddSystemButton({ className }: { className?: string }) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -61,25 +63,33 @@ 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`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function copyInstallCommand(port = "45876", publicKey: string) {
|
function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
|
||||||
let cmd = `curl -sL https://raw.githubusercontent.com/henrygd/beszel/main/supplemental/scripts/install-agent.sh -o install-agent.sh && chmod +x install-agent.sh && ./install-agent.sh -p ${port} -k "${publicKey}"`
|
let cmd = `curl -sL https://get.beszel.dev${
|
||||||
// add china mirrors flag if zh-CN
|
brew ? "/brew" : ""
|
||||||
if ((i18n.locale + navigator.language).includes("zh-CN")) {
|
} -o /tmp/install-agent.sh && chmod +x /tmp/install-agent.sh && /tmp/install-agent.sh -p ${port} -k "${publicKey}"`
|
||||||
|
// brew script does not support --china-mirrors
|
||||||
|
if (!brew && (i18n.locale + navigator.language).includes("zh-CN")) {
|
||||||
cmd += ` --china-mirrors`
|
cmd += ` --china-mirrors`
|
||||||
}
|
}
|
||||||
copyToClipboard(cmd)
|
copyToClipboard(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function copyWindowsCommand(port = "45876", publicKey: string) {
|
||||||
|
copyToClipboard(
|
||||||
|
`Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser; & iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SystemDialog component for adding or editing a system.
|
* SystemDialog component for adding or editing a system.
|
||||||
* @param {Object} props - The component props.
|
* @param {Object} props - The component props.
|
||||||
@@ -91,6 +101,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 +129,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 +151,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.
|
||||||
@@ -195,7 +206,7 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
className="absolute end-0 top-0"
|
className="absolute end-0 top-0"
|
||||||
onClick={() => copyToClipboard(publicKey)}
|
onClick={() => copyToClipboard(publicKey)}
|
||||||
>
|
>
|
||||||
<Copy className="h-4 w-4 " />
|
<Copy className="size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
@@ -211,19 +222,41 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
{/* Docker */}
|
{/* Docker */}
|
||||||
<TabsContent value="docker" className="contents">
|
<TabsContent value="docker" className="contents">
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={t`Copy` + " docker compose"}
|
text={t({ message: "Copy docker compose", context: "Button to copy docker compose file content" })}
|
||||||
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
onClick={() => copyDockerCompose(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||||
dropdownText={t`Copy` + " docker run"}
|
icon={<DockerIcon className="size-4 -me-0.5" />}
|
||||||
dropdownOnClick={() => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
dropdownItems={[
|
||||||
|
{
|
||||||
|
text: t({ message: "Copy docker run", context: "Button to copy docker run command" }),
|
||||||
|
onClick: () => copyDockerRun(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
||||||
|
icons: [<DockerIcon className="size-4" />],
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{/* Binary */}
|
{/* Binary */}
|
||||||
<TabsContent value="binary" className="contents">
|
<TabsContent value="binary" className="contents">
|
||||||
<CopyButton
|
<CopyButton
|
||||||
text={t`Copy Linux command`}
|
text={t`Copy Linux command`}
|
||||||
onClick={() => copyInstallCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
icon={<TuxIcon className="size-4" />}
|
||||||
dropdownText={t`Manual setup instructions`}
|
onClick={() => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey)}
|
||||||
dropdownUrl="https://beszel.dev/guide/agent-installation#binary"
|
dropdownItems={[
|
||||||
|
{
|
||||||
|
text: t({ message: "Homebrew command", context: "Button to copy install command" }),
|
||||||
|
onClick: () => copyLinuxCommand(isUnixSocket ? hostValue : port.current?.value, publicKey, true),
|
||||||
|
icons: [<AppleIcon className="size-4" />, <TuxIcon className="w-4 h-4" />],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t({ message: "Windows command", context: "Button to copy install command" }),
|
||||||
|
onClick: () => copyWindowsCommand(isUnixSocket ? hostValue : port.current?.value, publicKey),
|
||||||
|
icons: [<WindowsIcon className="size-4" />],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: t`Manual setup instructions`,
|
||||||
|
url: "https://beszel.dev/guide/agent-installation#binary",
|
||||||
|
icons: [<ExternalLinkIcon className="size-4" />],
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
{/* Save */}
|
{/* Save */}
|
||||||
@@ -235,19 +268,30 @@ export const SystemDialog = memo(({ setOpen, system }: { setOpen: (open: boolean
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
interface DropdownItem {
|
||||||
|
text: string
|
||||||
|
onClick?: () => void
|
||||||
|
url?: string
|
||||||
|
icons?: React.ReactNode[]
|
||||||
|
}
|
||||||
|
|
||||||
interface CopyButtonProps {
|
interface CopyButtonProps {
|
||||||
text: string
|
text: string
|
||||||
onClick: () => void
|
onClick: () => void
|
||||||
dropdownText: string
|
dropdownItems: DropdownItem[]
|
||||||
dropdownOnClick?: () => void
|
icon?: React.ReactNode
|
||||||
dropdownUrl?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CopyButton = memo((props: CopyButtonProps) => {
|
const CopyButton = memo((props: CopyButtonProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-0 rounded-lg">
|
<div className="flex gap-0 rounded-lg">
|
||||||
<Button type="button" variant="outline" onClick={props.onClick} className="rounded-e-none dark:border-e-0 grow">
|
<Button
|
||||||
{props.text}
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={props.onClick}
|
||||||
|
className="rounded-e-none dark:border-e-0 grow flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{props.text} {props.icon}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-full bg-muted"></div>
|
<div className="w-px h-full bg-muted"></div>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -257,15 +301,24 @@ const CopyButton = memo((props: CopyButtonProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{props.dropdownUrl ? (
|
{props.dropdownItems.map((item, index) => (
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem key={index} asChild={!!item.url}>
|
||||||
<a href={props.dropdownUrl} target="_blank" rel="noopener noreferrer">
|
{item.url ? (
|
||||||
{props.dropdownText}
|
<a
|
||||||
</a>
|
href={item.url}
|
||||||
|
className="cursor-pointer flex items-center gap-1.5"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{item.text} {item.icons?.map((icon) => icon)}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<div onClick={item.onClick} className="cursor-pointer flex items-center gap-1.5">
|
||||||
|
{item.text} {item.icons?.map((icon) => icon)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
) : (
|
))}
|
||||||
<DropdownMenuItem onClick={props.dropdownOnClick}>{props.dropdownText}</DropdownMenuItem>
|
|
||||||
)}
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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,175 +68,235 @@ 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} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function AlertContent({ data }: { data: AlertData }) {
|
/**
|
||||||
const { key } = 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 hasSliders = !("single" in data.alert)
|
const create = async <T extends Record<string, any>>(options: T) => {
|
||||||
|
batch ||= pb.createBatch()
|
||||||
|
batch.collection(collection).create(options)
|
||||||
|
if (++count >= batchSize) {
|
||||||
|
await send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const update = async <T extends Record<string, any>>(id: string, data: T) => {
|
||||||
|
batch ||= pb.createBatch()
|
||||||
|
batch.collection(collection).update(id, data)
|
||||||
|
if (++count >= batchSize) {
|
||||||
|
await send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (id: string) => {
|
||||||
|
batch ||= pb.createBatch()
|
||||||
|
batch.collection(collection).delete(id)
|
||||||
|
if (++count >= batchSize) {
|
||||||
|
await send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async () => {
|
||||||
|
if (count) {
|
||||||
|
await batch?.send({ requestKey: null })
|
||||||
|
batch = undefined
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
update,
|
||||||
|
remove,
|
||||||
|
send,
|
||||||
|
create,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertContent({ data }: { data: AlertData }) {
|
||||||
|
const { name } = data
|
||||||
|
|
||||||
|
const singleDescription = data.alert.singleDesc?.()
|
||||||
|
|
||||||
const [checked, setChecked] = useState(data.checked || false)
|
const [checked, setChecked] = useState(data.checked || false)
|
||||||
const [min, setMin] = useState(data.min || (hasSliders ? 10 : 0))
|
const [min, setMin] = useState(data.min || 10)
|
||||||
const [value, setValue] = useState(data.val || (hasSliders ? 80 : 0))
|
const [value, setValue] = useState(data.val || (singleDescription ? 0 : 80))
|
||||||
|
|
||||||
const showSliders = checked && hasSliders
|
const Icon = alertInfo[name].icon
|
||||||
|
|
||||||
const newMin = useRef(min)
|
|
||||||
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": showSliders,
|
"pb-0": checked,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="grid gap-1 select-none">
|
<div className="grid gap-1 select-none">
|
||||||
<p className="font-semibold flex gap-3 items-center">
|
<p className="font-semibold flex gap-3 items-center">
|
||||||
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
<Icon className="h-4 w-4 opacity-85" /> {data.alert.name()}
|
||||||
</p>
|
</p>
|
||||||
{!showSliders && <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>
|
||||||
{showSliders && (
|
{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" />}>
|
||||||
<div>
|
{!singleDescription && (
|
||||||
<p id={`v${key}`} className="text-sm block h-8">
|
<div>
|
||||||
<Trans>
|
<p id={`v${name}`} className="text-sm block h-8">
|
||||||
Average exceeds{" "}
|
<Trans>
|
||||||
<strong className="text-foreground">
|
Average exceeds{" "}
|
||||||
{value}
|
<strong className="text-foreground">
|
||||||
{data.alert.unit}
|
{value}
|
||||||
</strong>
|
{data.alert.unit}
|
||||||
</Trans>
|
</strong>
|
||||||
</p>
|
</Trans>
|
||||||
<div className="flex gap-3">
|
</p>
|
||||||
<Slider
|
<div className="flex gap-3">
|
||||||
aria-labelledby={`v${key}`}
|
<Slider
|
||||||
defaultValue={[value]}
|
aria-labelledby={`v${name}`}
|
||||||
onValueCommit={(val) => (newValue.current = val[0]) && updateAlert()}
|
defaultValue={[value]}
|
||||||
onValueChange={(val) => setValue(val[0])}
|
onValueCommit={(val) => {
|
||||||
min={1}
|
data.updateAlert?.(true, val[0], min)
|
||||||
max={alertInfo[key].max ?? 99}
|
}}
|
||||||
/>
|
onValueChange={(val) => {
|
||||||
|
setValue(val[0])
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={alertInfo[name].max ?? 99}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div>
|
<div className={cn(singleDescription && "col-span-full lowercase")}>
|
||||||
<p id={`t${key}`} className="text-sm block h-8">
|
<p id={`t${name}`} className="text-sm block h-8 first-letter:uppercase">
|
||||||
|
{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"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,16 +16,17 @@ import { useStore } from "@nanostores/react"
|
|||||||
import { $containerFilter } from "@/lib/stores"
|
import { $containerFilter } from "@/lib/stores"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { Separator } from "../ui/separator"
|
import { Separator } from "../ui/separator"
|
||||||
|
import { ChartType } from "@/lib/enums"
|
||||||
|
|
||||||
export default memo(function ContainerChart({
|
export default memo(function ContainerChart({
|
||||||
dataKey,
|
dataKey,
|
||||||
chartData,
|
chartData,
|
||||||
chartName,
|
chartType,
|
||||||
unit = "%",
|
unit = "%",
|
||||||
}: {
|
}: {
|
||||||
dataKey: string
|
dataKey: string
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
chartName: string
|
chartType: ChartType
|
||||||
unit?: string
|
unit?: string
|
||||||
}) {
|
}) {
|
||||||
const filter = useStore($containerFilter)
|
const filter = useStore($containerFilter)
|
||||||
@@ -33,7 +34,7 @@ export default memo(function ContainerChart({
|
|||||||
|
|
||||||
const { containerData } = chartData
|
const { containerData } = chartData
|
||||||
|
|
||||||
const isNetChart = chartName === "net"
|
const isNetChart = chartType === ChartType.Network
|
||||||
|
|
||||||
const chartConfig = useMemo(() => {
|
const chartConfig = useMemo(() => {
|
||||||
let config = {} as Record<
|
let config = {} as Record<
|
||||||
@@ -81,7 +82,7 @@ export default memo(function ContainerChart({
|
|||||||
tickFormatter: (value: any) => string
|
tickFormatter: (value: any) => string
|
||||||
}
|
}
|
||||||
// tick formatter
|
// tick formatter
|
||||||
if (chartName === "cpu") {
|
if (chartType === ChartType.CPU) {
|
||||||
obj.tickFormatter = (value) => {
|
obj.tickFormatter = (value) => {
|
||||||
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
const val = toFixedWithoutTrailingZeros(value, 2) + unit
|
||||||
return updateYAxisWidth(val)
|
return updateYAxisWidth(val)
|
||||||
@@ -111,6 +112,11 @@ export default memo(function ContainerChart({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (chartType === ChartType.Memory) {
|
||||||
|
obj.toolTipFormatter = (item: any) => {
|
||||||
|
const { v, u } = getSizeAndUnit(item.value, false)
|
||||||
|
return decimalString(v, 2) + u
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
obj.toolTipFormatter = (item: any) => decimalString(item.value) + unit
|
||||||
}
|
}
|
||||||
@@ -157,13 +163,14 @@ export default memo(function ContainerChart({
|
|||||||
<ChartTooltip
|
<ChartTooltip
|
||||||
animationEasing="ease-out"
|
animationEasing="ease-out"
|
||||||
animationDuration={150}
|
animationDuration={150}
|
||||||
|
truncate={true}
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
itemSorter={(a, b) => b.value - a.value}
|
itemSorter={(a, b) => b.value - a.value}
|
||||||
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} />}
|
||||||
/>
|
/>
|
||||||
{Object.keys(chartConfig).map((key) => {
|
{Object.keys(chartConfig).map((key) => {
|
||||||
const filtered = filter && !key.includes(filter)
|
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
||||||
let fillOpacity = filtered ? 0.05 : 0.4
|
let fillOpacity = filtered ? 0.05 : 0.4
|
||||||
let strokeOpacity = filtered ? 0.1 : 1
|
let strokeOpacity = filtered ? 0.1 : 1
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ import {
|
|||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
import { ChartData } from "@/types"
|
import { ChartData } from "@/types"
|
||||||
import { memo, useMemo } from "react"
|
import { memo, useMemo } from "react"
|
||||||
|
import { $temperatureFilter } from "@/lib/stores"
|
||||||
|
import { useStore } from "@nanostores/react"
|
||||||
|
|
||||||
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
|
||||||
|
const filter = useStore($temperatureFilter)
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
|
|
||||||
if (chartData.systemStats.length === 0) {
|
if (chartData.systemStats.length === 0) {
|
||||||
@@ -86,22 +89,28 @@ export default memo(function TemperatureChart({ chartData }: { chartData: ChartD
|
|||||||
<ChartTooltipContent
|
<ChartTooltipContent
|
||||||
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
|
||||||
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
contentFormatter={(item) => decimalString(item.value) + " °C"}
|
||||||
// indicator="line"
|
filter={filter}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{colors.map((key) => (
|
{colors.map((key) => {
|
||||||
<Line
|
const filtered = filter && !key.toLowerCase().includes(filter.toLowerCase())
|
||||||
key={key}
|
let strokeOpacity = filtered ? 0.1 : 1
|
||||||
dataKey={key}
|
return (
|
||||||
name={key}
|
<Line
|
||||||
type="monotoneX"
|
key={key}
|
||||||
dot={false}
|
dataKey={key}
|
||||||
strokeWidth={1.5}
|
name={key}
|
||||||
stroke={newChartData.colors[key]}
|
type="monotoneX"
|
||||||
isAnimationActive={false}
|
dot={false}
|
||||||
/>
|
strokeWidth={1.5}
|
||||||
))}
|
stroke={newChartData.colors[key]}
|
||||||
|
strokeOpacity={strokeOpacity}
|
||||||
|
activeDot={{ opacity: filtered ? 0 : 1 }}
|
||||||
|
isAnimationActive={false}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
|
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
|
||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
|
|||||||
@@ -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, prependBasePath } 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(prependBasePath("/_/"), "_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(prependBasePath("/_/#/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(prependBasePath("/_/#/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(prependBasePath("/_/#/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 }) {
|
||||||
@@ -46,11 +46,11 @@ export default function SettingsProfilePage({ userSettings }: { userSettings: Us
|
|||||||
</h3>
|
</h3>
|
||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
<Trans>
|
||||||
Want to help us make our translations even better? Check out{" "}
|
Want to help improve our translations? Check{" "}
|
||||||
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
|
<a href="https://crowdin.com/project/beszel" className="link" target="_blank" rel="noopener noreferrer">
|
||||||
Crowdin
|
Crowdin
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
for more details.
|
for details.
|
||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -127,7 +128,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
<Trans>
|
<Trans>
|
||||||
Beszel uses{" "}
|
Beszel uses{" "}
|
||||||
<a href="https://containrrr.dev/shoutrrr/services/overview/" target="_blank" className="link">
|
<a href="https://beszel.dev/guide/notifications" target="_blank" className="link">
|
||||||
Shoutrrr
|
Shoutrrr
|
||||||
</a>{" "}
|
</a>{" "}
|
||||||
to integrate with popular notification services.
|
to integrate with popular notification services.
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import { $systems, pb, $chartTime, $containerFilter, $userSettings, $direction, $maxValues } from "@/lib/stores"
|
import { t } from "@lingui/core/macro"
|
||||||
|
import { Plural, Trans } from "@lingui/react/macro"
|
||||||
|
import {
|
||||||
|
$systems,
|
||||||
|
pb,
|
||||||
|
$chartTime,
|
||||||
|
$containerFilter,
|
||||||
|
$userSettings,
|
||||||
|
$direction,
|
||||||
|
$maxValues,
|
||||||
|
$temperatureFilter,
|
||||||
|
} from "@/lib/stores"
|
||||||
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
import { ChartData, ChartTimes, ContainerStatsRecord, GPUData, SystemRecord, SystemStatsRecord } from "@/types"
|
||||||
|
import { ChartType, Os } from "@/lib/enums"
|
||||||
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
import React, { lazy, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
@@ -12,6 +24,7 @@ import {
|
|||||||
getHostDisplayValue,
|
getHostDisplayValue,
|
||||||
getPbTimestamp,
|
getPbTimestamp,
|
||||||
getSizeAndUnit,
|
getSizeAndUnit,
|
||||||
|
listen,
|
||||||
toFixedFloat,
|
toFixedFloat,
|
||||||
useLocalStorage,
|
useLocalStorage,
|
||||||
} from "@/lib/utils"
|
} from "@/lib/utils"
|
||||||
@@ -19,12 +32,13 @@ import { Separator } from "../ui/separator"
|
|||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"
|
||||||
import { Button } from "../ui/button"
|
import { Button } from "../ui/button"
|
||||||
import { Input } from "../ui/input"
|
import { Input } from "../ui/input"
|
||||||
import { ChartAverage, ChartMax, Rows, TuxIcon } from "../ui/icons"
|
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } 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 +117,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 +126,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 +135,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)
|
||||||
@@ -211,7 +228,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
cache.set(cs_cache_key, containerData)
|
cache.set(cs_cache_key, containerData)
|
||||||
}
|
}
|
||||||
if (containerData.length) {
|
if (containerData.length) {
|
||||||
!containerFilterBar && setContainerFilterBar(<ContainerFilterBar />)
|
!containerFilterBar && setContainerFilterBar(<FilterBar />)
|
||||||
} else if (containerFilterBar) {
|
} else if (containerFilterBar) {
|
||||||
setContainerFilterBar(null)
|
setContainerFilterBar(null)
|
||||||
}
|
}
|
||||||
@@ -244,6 +261,27 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
if (!system.info) {
|
if (!system.info) {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const osInfo = {
|
||||||
|
[Os.Linux]: {
|
||||||
|
Icon: TuxIcon,
|
||||||
|
value: system.info.k,
|
||||||
|
label: t({ comment: "Linux kernel", message: "Kernel" }),
|
||||||
|
},
|
||||||
|
[Os.Darwin]: {
|
||||||
|
Icon: AppleIcon,
|
||||||
|
value: `macOS ${system.info.k}`,
|
||||||
|
},
|
||||||
|
[Os.Windows]: {
|
||||||
|
Icon: WindowsIcon,
|
||||||
|
value: system.info.k,
|
||||||
|
},
|
||||||
|
[Os.FreeBSD]: {
|
||||||
|
Icon: FreeBsdIcon,
|
||||||
|
value: system.info.k,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
let uptime: React.ReactNode
|
let uptime: React.ReactNode
|
||||||
if (system.info.u < 172800) {
|
if (system.info.u < 172800) {
|
||||||
const hours = Math.trunc(system.info.u / 3600)
|
const hours = Math.trunc(system.info.u / 3600)
|
||||||
@@ -260,8 +298,8 @@ 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" }) },
|
osInfo[system.info.os ?? Os.Linux],
|
||||||
{
|
{
|
||||||
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` : ""})`,
|
||||||
Icon: CpuIcon,
|
Icon: CpuIcon,
|
||||||
@@ -289,6 +327,41 @@ 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 ||
|
||||||
|
e.shiftKey ||
|
||||||
|
e.ctrlKey ||
|
||||||
|
e.metaKey
|
||||||
|
) {
|
||||||
|
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
|
||||||
}
|
}
|
||||||
@@ -302,6 +375,13 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
const hasGpuData = lastGpuVals.length > 0
|
const hasGpuData = lastGpuVals.length > 0
|
||||||
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
const hasGpuPowerData = lastGpuVals.some((gpu) => gpu.p !== undefined)
|
||||||
|
|
||||||
|
let translatedStatus: string = system.status
|
||||||
|
if (system.status === "up") {
|
||||||
|
translatedStatus = t({ message: "Up", comment: "Context: System is up" })
|
||||||
|
} else if (system.status === "down") {
|
||||||
|
translatedStatus = t({ message: "Down", comment: "Context: System is down" })
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
<div id="chartwrap" className="grid gap-4 mb-10 overflow-x-clip">
|
||||||
@@ -328,7 +408,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
})}
|
})}
|
||||||
></span>
|
></span>
|
||||||
</span>
|
</span>
|
||||||
{system.status}
|
{translatedStatus}
|
||||||
</div>
|
</div>
|
||||||
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
{systemInfo.map(({ value, label, Icon, hide }, i) => {
|
||||||
if (hide || !value) {
|
if (hide || !value) {
|
||||||
@@ -388,7 +468,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}
|
||||||
>
|
>
|
||||||
@@ -403,7 +483,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={t`Average CPU utilization of containers`}
|
description={t`Average CPU utilization of containers`}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ContainerChart chartData={chartData} dataKey="c" chartName="cpu" />
|
<ContainerChart chartData={chartData} dataKey="c" chartType={ChartType.CPU} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -424,7 +504,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
description={dockerOrPodman(t`Memory usage of docker containers`, system)}
|
||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
<ContainerChart chartData={chartData} chartName="mem" dataKey="m" unit=" MB" />
|
<ContainerChart chartData={chartData} dataKey="m" chartType={ChartType.Memory} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -466,7 +546,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
cornerEl={containerFilterBar}
|
cornerEl={containerFilterBar}
|
||||||
>
|
>
|
||||||
{/* @ts-ignore */}
|
{/* @ts-ignore */}
|
||||||
<ContainerChart chartData={chartData} chartName="net" dataKey="n" />
|
<ContainerChart chartData={chartData} chartType={ChartType.Network} dataKey="n" />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -490,6 +570,7 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
grid={grid}
|
grid={grid}
|
||||||
title={t`Temperature`}
|
title={t`Temperature`}
|
||||||
description={t`Temperatures of system sensors`}
|
description={t`Temperatures of system sensors`}
|
||||||
|
cornerEl={<FilterBar store={$temperatureFilter} />}
|
||||||
>
|
>
|
||||||
<TemperatureChart chartData={chartData} />
|
<TemperatureChart chartData={chartData} />
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
@@ -513,6 +594,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
|
||||||
@@ -532,12 +617,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>
|
||||||
@@ -586,17 +668,17 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ContainerFilterBar() {
|
function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
|
||||||
const containerFilter = useStore($containerFilter)
|
const containerFilter = useStore(store)
|
||||||
const { _ } = useLingui()
|
const { t } = useLingui()
|
||||||
|
|
||||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
$containerFilter.set(e.target.value)
|
store.set(e.target.value)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
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"
|
||||||
@@ -604,7 +686,7 @@ function ContainerFilterBar() {
|
|||||||
size="icon"
|
size="icon"
|
||||||
aria-label="Clear"
|
aria-label="Clear"
|
||||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
|
||||||
onClick={() => $containerFilter.set("")}
|
onClick={() => store.set("")}
|
||||||
>
|
>
|
||||||
<XIcon className="h-4 w-4" />
|
<XIcon className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
unit?: string
|
unit?: string
|
||||||
filter?: string
|
filter?: string
|
||||||
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
contentFormatter?: (item: any, key: string) => React.ReactNode | string
|
||||||
|
truncate?: boolean
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -119,6 +120,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
filter,
|
filter,
|
||||||
itemSorter,
|
itemSorter,
|
||||||
contentFormatter: content = undefined,
|
contentFormatter: content = undefined,
|
||||||
|
truncate = false,
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -127,7 +129,7 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
|
|
||||||
React.useMemo(() => {
|
React.useMemo(() => {
|
||||||
if (filter) {
|
if (filter) {
|
||||||
payload = payload?.filter((item) => (item.name as string)?.includes(filter))
|
payload = payload?.filter((item) => (item.name as string)?.toLowerCase().includes(filter.toLowerCase()))
|
||||||
}
|
}
|
||||||
if (itemSorter) {
|
if (itemSorter) {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -214,10 +216,15 @@ const ChartTooltipContent = React.forwardRef<
|
|||||||
nestLabel ? "items-end" : "items-center"
|
nestLabel ? "items-end" : "items-center"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="grid gap-1.5">
|
{nestLabel ? tooltipLabel : null}
|
||||||
{nestLabel ? tooltipLabel : null}
|
<span
|
||||||
<span className="text-muted-foreground">{itemConfig?.label || item.name}</span>
|
className={cn(
|
||||||
</div>
|
"text-muted-foreground",
|
||||||
|
truncate ? "max-w-40 truncate leading-normal -my-1" : ""
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{itemConfig?.label || item.name}
|
||||||
|
</span>
|
||||||
{item.value !== undefined && (
|
{item.value !== undefined && (
|
||||||
<span className="font-medium tabular-nums text-foreground">
|
<span className="font-medium tabular-nums text-foreground">
|
||||||
{content && typeof content === "function"
|
{content && typeof content === "function"
|
||||||
|
|||||||
@@ -12,6 +12,54 @@ export function TuxIcon(props: SVGProps<SVGSVGElement>) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// icon park (Apache 2.0) https://github.com/bytedance/IconPark/blob/master/LICENSE
|
||||||
|
export function WindowsIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 48 48">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="3.8"
|
||||||
|
d="m6.8 11 12.9-1.7v12.1h-13zm18-2.2 16.4-2v14.6H25zm0 18.6 16.4.4v13.4L25 38.6zm-18-.8 12.9.3v10.9l-13-2.2z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// teenyicons (MIT) https://github.com/teenyicons/teenyicons/blob/master/LICENSE
|
||||||
|
export function AppleIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 20 20" {...props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M14.1 4.7a5 5 0 0 1 3.8 2c-3.3 1.9-2.8 6.7.6 8L17.2 17c-.8 1.3-2 2.9-3.5 2.9-1.2 0-1.6-.9-3.3-.8s-2.2.8-3.5.8c-1.4 0-2.5-1.5-3.4-2.7-2.3-3.6-2.5-7.9-1.1-10 1-1.7 2.6-2.6 4.1-2.6 1.6 0 2.6.8 3.8.8 1.3 0 2-.8 3.8-.8M13.7 0c.2 1.2-.3 2.4-1 3.2a4 4 0 0 1-3 1.6c-.2-1.2.3-2.3 1-3.2.7-.8 2-1.5 3-1.6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apache 2.0 https://github.com/Templarian/MaterialDesign/blob/master/LICENSE
|
||||||
|
export function FreeBsdIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" {...props}>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M2.7 2C3.5 2 6 3.2 6 3.2 4.8 4 3.7 5 3 6.4 2.1 4.8 1.3 2.9 2 2.2l.7-.2m18.1.1c.4 0 .8 0 1 .2 1 1.1-2 5.8-2.4 6.4-.5.5-1.8 0-2.9-1-1-1.2-1.5-2.4-1-3 .4-.4 3.6-2.4 5.3-2.6m-8.8.5c1.3 0 2.5.2 3.7.7l-1 .7c-1 1-.6 2.8 1 4.4 1 1 2.1 1.6 3 1.6a2 2 0 0 0 1.5-.6l.7-1a9.7 9.7 0 1 1-18.6 3.8A9.7 9.7 0 0 1 12 2.7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ion icons (MIT) https://github.com/ionic-team/ionicons/blob/main/LICENSE
|
||||||
|
export function DockerIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg {...props} viewBox="0 0 512 512" fill="currentColor">
|
||||||
|
<path d="M507 211c-1-1-14-11-42-11a133 133 0 0 0-21 2c-6-36-36-54-37-55l-7-4-5 7a102 102 0 0 0-13 30c-5 21-2 40 8 57-12 7-33 9-37 9H16a16 16 0 0 0-16 16 241 241 0 0 0 15 87c11 30 29 53 51 67 25 15 66 24 113 24a344 344 0 0 0 62-6 257 257 0 0 0 82-29 224 224 0 0 0 55-46c27-30 43-64 55-94h4c30 0 48-12 58-22a63 63 0 0 0 15-22l2-6Z" />
|
||||||
|
<path d="M47 236h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4H47a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m-125-57h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m63 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m62 0h45a4 4 0 0 0 4-4v-41a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v41a4 4 0 0 0 4 4m0-58h45a4 4 0 0 0 4-4V76a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4m63 116h45a4 4 0 0 0 4-4v-40a4 4 0 0 0-4-4h-45a4 4 0 0 0-4 4v40a4 4 0 0 0 4 4" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
|
// MingCute Apache License 2.0 https://github.com/Richard9394/MingCute
|
||||||
export function Rows(props: SVGProps<SVGSVGElement>) {
|
export function Rows(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
13
beszel/site/src/lib/enums.ts
Normal file
13
beszel/site/src/lib/enums.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export enum Os {
|
||||||
|
Linux = 0,
|
||||||
|
Darwin,
|
||||||
|
Windows,
|
||||||
|
FreeBSD,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ChartType {
|
||||||
|
Memory,
|
||||||
|
Disk,
|
||||||
|
Network,
|
||||||
|
CPU,
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
@@ -41,6 +38,9 @@ $userSettings.subscribe((value) => {
|
|||||||
/** Container chart filter */
|
/** Container chart filter */
|
||||||
export const $containerFilter = atom("")
|
export const $containerFilter = atom("")
|
||||||
|
|
||||||
|
/** Temperature chart filter */
|
||||||
|
export const $temperatureFilter = atom("")
|
||||||
|
|
||||||
/** Fallback copy to clipboard dialog content */
|
/** Fallback copy to clipboard dialog content */
|
||||||
export const $copyContent = atom("")
|
export const $copyContent = atom("")
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
@@ -302,7 +312,8 @@ export const alertInfo: Record<string, AlertInfo> = {
|
|||||||
unit: "",
|
unit: "",
|
||||||
icon: ServerIcon,
|
icon: ServerIcon,
|
||||||
desc: () => t`Triggers when status switches between up and down`,
|
desc: () => t`Triggers when status switches between up and down`,
|
||||||
single: true,
|
/** "for x minutes" is appended to desc when only one value */
|
||||||
|
singleDesc: () => t`System` + " " + t`Down`,
|
||||||
},
|
},
|
||||||
CPU: {
|
CPU: {
|
||||||
name: () => t`CPU Usage`,
|
name: () => t`CPU Usage`,
|
||||||
|
|||||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user