mirror of
https://github.com/henrygd/beszel.git
synced 2025-12-06 03:35:33 +00:00
Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d5ec267d | ||
|
|
ca7642cc91 | ||
|
|
68009c85a5 | ||
|
|
1c7c64c4aa | ||
|
|
b05966d30b | ||
|
|
ea90f6a596 | ||
|
|
f1e43b2593 | ||
|
|
748d18321d | ||
|
|
ae84919c39 | ||
|
|
b23221702e | ||
|
|
4d5b096230 | ||
|
|
7caf7d1b31 | ||
|
|
6107f52d07 | ||
|
|
f4fb7a89e5 | ||
|
|
5439066f4d | ||
|
|
7c18f3d8b4 | ||
|
|
63af81666b | ||
|
|
c0a6153a43 | ||
|
|
df334caca6 | ||
|
|
ffb3ec0477 | ||
|
|
3a97edd0d5 | ||
|
|
ab1d1c1273 | ||
|
|
0fb39edae4 | ||
|
|
3a977a8e1f | ||
|
|
081979de24 | ||
|
|
23fe189797 | ||
|
|
e9d429b9b8 | ||
|
|
99202c85b6 |
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:
|
||||||
- 'xv*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ builds:
|
|||||||
- arm
|
- arm
|
||||||
- mips64
|
- mips64
|
||||||
- riscv64
|
- riscv64
|
||||||
|
- mipsle
|
||||||
|
- ppc64le
|
||||||
ignore:
|
ignore:
|
||||||
- goos: freebsd
|
- goos: freebsd
|
||||||
goarch: arm
|
goarch: arm
|
||||||
@@ -51,7 +53,7 @@ builds:
|
|||||||
|
|
||||||
archives:
|
archives:
|
||||||
- id: beszel-agent
|
- id: beszel-agent
|
||||||
format: tar.gz
|
formats: [tar.gz]
|
||||||
builds:
|
builds:
|
||||||
- beszel-agent
|
- beszel-agent
|
||||||
name_template: >-
|
name_template: >-
|
||||||
@@ -60,10 +62,10 @@ archives:
|
|||||||
{{- .Arch }}
|
{{- .Arch }}
|
||||||
format_overrides:
|
format_overrides:
|
||||||
- goos: windows
|
- goos: windows
|
||||||
format: zip
|
formats: [zip]
|
||||||
|
|
||||||
- id: beszel
|
- id: beszel
|
||||||
format: tar.gz
|
formats: [tar.gz]
|
||||||
builds:
|
builds:
|
||||||
- beszel
|
- beszel
|
||||||
name_template: >-
|
name_template: >-
|
||||||
@@ -87,9 +89,6 @@ nfpms:
|
|||||||
- beszel-agent
|
- beszel-agent
|
||||||
formats:
|
formats:
|
||||||
- deb
|
- deb
|
||||||
# don't think this is needed with CGO_ENABLED=0
|
|
||||||
# dependencies:
|
|
||||||
# - libc6
|
|
||||||
contents:
|
contents:
|
||||||
- src: ../supplemental/debian/beszel-agent.service
|
- src: ../supplemental/debian/beszel-agent.service
|
||||||
dst: lib/systemd/system/beszel-agent.service
|
dst: lib/systemd/system/beszel-agent.service
|
||||||
@@ -172,6 +171,44 @@ brews:
|
|||||||
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
error_log_path "#{Dir.home}/.cache/beszel/beszel-agent.log"
|
||||||
keep_alive true
|
keep_alive true
|
||||||
|
restart_delay 5
|
||||||
|
name beszel-agent
|
||||||
|
process_type :background
|
||||||
|
|
||||||
|
winget:
|
||||||
|
- ids: [beszel-agent]
|
||||||
|
name: beszel-agent
|
||||||
|
package_identifier: henrygd.beszel-agent
|
||||||
|
publisher: henrygd
|
||||||
|
license: MIT
|
||||||
|
license_url: 'https://github.com/henrygd/beszel/blob/main/LICENSE'
|
||||||
|
copyright: '2025 henrygd'
|
||||||
|
homepage: 'https://beszel.dev'
|
||||||
|
release_notes_url: 'https://github.com/henrygd/beszel/releases/tag/v{{ .Version }}'
|
||||||
|
publisher_support_url: 'https://github.com/henrygd/beszel/issues'
|
||||||
|
short_description: 'Agent for Beszel, a lightweight server monitoring platform.'
|
||||||
|
skip_upload: auto
|
||||||
|
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.
|
||||||
|
tags:
|
||||||
|
- homelab
|
||||||
|
- monitoring
|
||||||
|
- self-hosted
|
||||||
|
repository:
|
||||||
|
owner: henrygd
|
||||||
|
name: beszel-winget
|
||||||
|
branch: henrygd.beszel-agent-{{ .Version }}
|
||||||
|
pull_request:
|
||||||
|
enabled: false
|
||||||
|
draft: false
|
||||||
|
base:
|
||||||
|
owner: microsoft
|
||||||
|
name: winget-pkgs
|
||||||
|
branch: master
|
||||||
|
|
||||||
release:
|
release:
|
||||||
draft: true
|
draft: true
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type Agent struct {
|
|||||||
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
|
cache *SessionCache // Cache for system stats based on primary session ID
|
||||||
|
smartManager *SmartManager // Manages SMART data
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAgent() *Agent {
|
func NewAgent() *Agent {
|
||||||
@@ -62,6 +63,12 @@ func NewAgent() *Agent {
|
|||||||
agent.gpuManager = gm
|
agent.gpuManager = gm
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sm, err := NewSmartManager(); err != nil {
|
||||||
|
slog.Debug("SMART", "err", err)
|
||||||
|
} else {
|
||||||
|
agent.smartManager = sm
|
||||||
|
}
|
||||||
|
|
||||||
// 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(""))
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
|
|||||||
|
|
||||||
systemStats.Temperatures = make(map[string]float64, len(temps))
|
systemStats.Temperatures = make(map[string]float64, len(temps))
|
||||||
for i, sensor := range temps {
|
for i, sensor := range temps {
|
||||||
|
// scale temperature
|
||||||
|
if sensor.Temperature != 0 && sensor.Temperature < 1 {
|
||||||
|
sensor.Temperature = scaleTemperature(sensor.Temperature)
|
||||||
|
}
|
||||||
// skip if temperature is unreasonable
|
// skip if temperature is unreasonable
|
||||||
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
if sensor.Temperature <= 0 || sensor.Temperature >= 200 {
|
||||||
continue
|
continue
|
||||||
@@ -141,3 +145,19 @@ func isValidSensor(sensorName string, config *SensorConfig) bool {
|
|||||||
|
|
||||||
return config.isBlacklist
|
return config.isBlacklist
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scaleTemperature scales temperatures in fractional values to reasonable Celsius values
|
||||||
|
func scaleTemperature(temp float64) float64 {
|
||||||
|
if temp > 1 {
|
||||||
|
return temp
|
||||||
|
}
|
||||||
|
scaled100 := temp * 100
|
||||||
|
scaled1000 := temp * 1000
|
||||||
|
|
||||||
|
if scaled100 >= 15 && scaled100 <= 95 {
|
||||||
|
return scaled100
|
||||||
|
} else if scaled1000 >= 15 && scaled1000 <= 95 {
|
||||||
|
return scaled1000
|
||||||
|
}
|
||||||
|
return scaled100
|
||||||
|
}
|
||||||
|
|||||||
@@ -372,3 +372,85 @@ func TestNewSensorConfig(t *testing.T) {
|
|||||||
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
require.True(t, ok, "EnvMap should contain HostSysEnvKey")
|
||||||
assert.Equal(t, "/test/path", sysPath)
|
assert.Equal(t, "/test/path", sysPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScaleTemperature(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input float64
|
||||||
|
expected float64
|
||||||
|
desc string
|
||||||
|
}{
|
||||||
|
// Normal temperatures (no scaling needed)
|
||||||
|
{"normal_cpu_temp", 45.0, 45.0, "Normal CPU temperature"},
|
||||||
|
{"normal_room_temp", 25.0, 25.0, "Normal room temperature"},
|
||||||
|
{"high_cpu_temp", 85.0, 85.0, "High CPU temperature"},
|
||||||
|
// Zero temperature
|
||||||
|
{"zero_temp", 0.0, 0.0, "Zero temperature"},
|
||||||
|
// Fractional values that should use 100x scaling
|
||||||
|
{"fractional_45c", 0.45, 45.0, "0.45 should become 45°C (100x)"},
|
||||||
|
{"fractional_25c", 0.25, 25.0, "0.25 should become 25°C (100x)"},
|
||||||
|
{"fractional_60c", 0.60, 60.0, "0.60 should become 60°C (100x)"},
|
||||||
|
{"fractional_75c", 0.75, 75.0, "0.75 should become 75°C (100x)"},
|
||||||
|
{"fractional_30c", 0.30, 30.0, "0.30 should become 30°C (100x)"},
|
||||||
|
// Fractional values that should use 1000x scaling
|
||||||
|
{"millifractional_45c", 0.045, 45.0, "0.045 should become 45°C (1000x)"},
|
||||||
|
{"millifractional_25c", 0.025, 25.0, "0.025 should become 25°C (1000x)"},
|
||||||
|
{"millifractional_60c", 0.060, 60.0, "0.060 should become 60°C (1000x)"},
|
||||||
|
{"millifractional_75c", 0.075, 75.0, "0.075 should become 75°C (1000x)"},
|
||||||
|
{"millifractional_35c", 0.035, 35.0, "0.035 should become 35°C (1000x)"},
|
||||||
|
// Edge cases - values outside reasonable range
|
||||||
|
{"very_low_fractional", 0.01, 1.0, "0.01 should default to 100x scaling (1°C)"},
|
||||||
|
{"very_high_fractional", 0.99, 99.0, "0.99 should default to 100x scaling (99°C)"},
|
||||||
|
{"extremely_low", 0.001, 0.1, "0.001 should default to 100x scaling (0.1°C)"},
|
||||||
|
// Boundary cases around the reasonable range (15-95°C)
|
||||||
|
{"boundary_low_100x", 0.15, 15.0, "0.15 should use 100x scaling (15°C)"},
|
||||||
|
{"boundary_high_100x", 0.95, 95.0, "0.95 should use 100x scaling (95°C)"},
|
||||||
|
{"boundary_low_1000x", 0.015, 15.0, "0.015 should use 1000x scaling (15°C)"},
|
||||||
|
{"boundary_high_1000x", 0.095, 95.0, "0.095 should use 1000x scaling (95°C)"},
|
||||||
|
// Values just outside reasonable range
|
||||||
|
{"just_below_range_100x", 0.14, 14.0, "0.14 should default to 100x (14°C)"},
|
||||||
|
{"just_above_range_100x", 0.96, 96.0, "0.96 should default to 100x (96°C)"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := scaleTemperature(tt.input)
|
||||||
|
assert.InDelta(t, tt.expected, result, 0.001,
|
||||||
|
"scaleTemperature(%v) = %v, expected %v (%s)",
|
||||||
|
tt.input, result, tt.expected, tt.desc)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScaleTemperatureLogic(t *testing.T) {
|
||||||
|
// Test the logic flow for ambiguous cases
|
||||||
|
t.Run("prefers_100x_when_both_valid", func(t *testing.T) {
|
||||||
|
// 0.5 could be 50°C (100x) or 500°C (1000x)
|
||||||
|
// Should prefer 100x since it's tried first and is in range
|
||||||
|
result := scaleTemperature(0.5)
|
||||||
|
expected := 50.0
|
||||||
|
assert.InDelta(t, expected, result, 0.001,
|
||||||
|
"scaleTemperature(0.5) = %v, expected %v (should prefer 100x scaling)",
|
||||||
|
result, expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("uses_1000x_when_100x_too_low", func(t *testing.T) {
|
||||||
|
// 0.05 -> 5°C (100x, too low) or 50°C (1000x, in range)
|
||||||
|
// Should use 1000x since 100x is below reasonable range
|
||||||
|
result := scaleTemperature(0.05)
|
||||||
|
expected := 50.0
|
||||||
|
assert.InDelta(t, expected, result, 0.001,
|
||||||
|
"scaleTemperature(0.05) = %v, expected %v (should use 1000x scaling)",
|
||||||
|
result, expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("defaults_to_100x_when_both_invalid", func(t *testing.T) {
|
||||||
|
// 0.005 -> 0.5°C (100x, too low) or 5°C (1000x, too low)
|
||||||
|
// Should default to 100x scaling
|
||||||
|
result := scaleTemperature(0.005)
|
||||||
|
expected := 0.5
|
||||||
|
assert.InDelta(t, expected, result, 0.001,
|
||||||
|
"scaleTemperature(0.005) = %v, expected %v (should default to 100x)",
|
||||||
|
result, expected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -8,19 +9,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
sshServer "github.com/gliderlabs/ssh"
|
"github.com/gliderlabs/ssh"
|
||||||
"golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServerOptions struct {
|
type ServerOptions struct {
|
||||||
Addr string
|
Addr string
|
||||||
Network string
|
Network string
|
||||||
Keys []ssh.PublicKey
|
Keys []gossh.PublicKey
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) StartServer(opts ServerOptions) error {
|
func (a *Agent) StartServer(opts ServerOptions) error {
|
||||||
sshServer.Handle(a.handleSession)
|
|
||||||
|
|
||||||
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
|
slog.Info("Starting SSH server", "addr", opts.Addr, "network", opts.Network)
|
||||||
|
|
||||||
if opts.Network == "unix" {
|
if opts.Network == "unix" {
|
||||||
@@ -37,33 +36,57 @@ func (a *Agent) StartServer(opts ServerOptions) error {
|
|||||||
}
|
}
|
||||||
defer ln.Close()
|
defer ln.Close()
|
||||||
|
|
||||||
// Start SSH server on the listener
|
// base config (limit to allowed algorithms)
|
||||||
return sshServer.Serve(ln, nil, sshServer.NoPty(),
|
config := &gossh.ServerConfig{}
|
||||||
sshServer.PublicKeyAuth(func(ctx sshServer.Context, key sshServer.PublicKey) bool {
|
config.KeyExchanges = common.DefaultKeyExchanges
|
||||||
|
config.MACs = common.DefaultMACs
|
||||||
|
config.Ciphers = common.DefaultCiphers
|
||||||
|
|
||||||
|
// set default handler
|
||||||
|
ssh.Handle(a.handleSession)
|
||||||
|
|
||||||
|
server := ssh.Server{
|
||||||
|
ServerConfigCallback: func(ctx ssh.Context) *gossh.ServerConfig {
|
||||||
|
return config
|
||||||
|
},
|
||||||
|
// check public key(s)
|
||||||
|
PublicKeyHandler: func(ctx ssh.Context, key ssh.PublicKey) bool {
|
||||||
for _, pubKey := range opts.Keys {
|
for _, pubKey := range opts.Keys {
|
||||||
if sshServer.KeysEqual(key, pubKey) {
|
if ssh.KeysEqual(key, pubKey) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}),
|
},
|
||||||
)
|
// disable pty
|
||||||
|
PtyCallback: func(ctx ssh.Context, pty ssh.Pty) bool {
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
// log failed connections
|
||||||
|
ConnectionFailedCallback: func(conn net.Conn, err error) {
|
||||||
|
slog.Warn("Failed connection attempt", "addr", conn.RemoteAddr().String(), "err", err)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start SSH server on the listener
|
||||||
|
return server.Serve(ln)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) handleSession(s sshServer.Session) {
|
func (a *Agent) handleSession(s ssh.Session) {
|
||||||
slog.Debug("New session", "client", s.RemoteAddr())
|
slog.Debug("New session", "client", s.RemoteAddr())
|
||||||
stats := a.gatherStats(s.Context().SessionID())
|
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)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
s.Exit(0)
|
s.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
|
// ParseKeys parses a string containing SSH public keys in authorized_keys format.
|
||||||
// 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) ([]gossh.PublicKey, error) {
|
||||||
var parsedKeys []ssh.PublicKey
|
var parsedKeys []gossh.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
|
||||||
@@ -71,7 +94,7 @@ func ParseKeys(input string) ([]ssh.PublicKey, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Parse the key
|
// Parse the key
|
||||||
parsedKey, _, _, _, err := ssh.ParseAuthorizedKey([]byte(line))
|
parsedKey, _, _, _, err := gossh.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)
|
||||||
}
|
}
|
||||||
|
|||||||
304
beszel/internal/agent/smart.go
Normal file
304
beszel/internal/agent/smart.go
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"beszel/internal/entities/smart"
|
||||||
|
"beszel/internal/entities/system"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"reflect"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/exp/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SmartManager manages data collection for SMART devices
|
||||||
|
// TODO: add retry argument
|
||||||
|
// TODO: add timeout argument
|
||||||
|
type SmartManager struct {
|
||||||
|
SmartDataMap map[string]*system.SmartData
|
||||||
|
SmartDevices []*DeviceInfo
|
||||||
|
mutex sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type scanOutput struct {
|
||||||
|
Devices []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
InfoName string `json:"info_name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
} `json:"devices"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
InfoName string `json:"info_name"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var errNoValidSmartData = fmt.Errorf("no valid GPU data found") // Error for missing data
|
||||||
|
|
||||||
|
// Starts the SmartManager
|
||||||
|
func (sm *SmartManager) Start() {
|
||||||
|
sm.SmartDataMap = make(map[string]*system.SmartData)
|
||||||
|
for {
|
||||||
|
err := sm.ScanDevices()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("smartctl scan failed, stopping", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// TODO: add retry logic
|
||||||
|
for _, deviceInfo := range sm.SmartDevices {
|
||||||
|
err := sm.CollectSmart(deviceInfo)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("smartctl collect failed, stopping", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sleep for 10 seconds before next scan
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCurrentData returns the current SMART data
|
||||||
|
func (sm *SmartManager) GetCurrentData() map[string]system.SmartData {
|
||||||
|
sm.mutex.Lock()
|
||||||
|
defer sm.mutex.Unlock()
|
||||||
|
result := make(map[string]system.SmartData)
|
||||||
|
for key, value := range sm.SmartDataMap {
|
||||||
|
result[key] = *value
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScanDevices scans for SMART devices
|
||||||
|
// Scan devices using `smartctl --scan -j`
|
||||||
|
// If scan fails, return error
|
||||||
|
// If scan succeeds, parse the output and update the SmartDevices slice
|
||||||
|
func (sm *SmartManager) ScanDevices() error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "smartctl", "--scan", "-j")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidData := sm.parseScan(output)
|
||||||
|
if !hasValidData {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectSmart collects SMART data for a device
|
||||||
|
// Collect data using `smartctl --all -j /dev/sdX` or `smartctl --all -j /dev/nvmeX`
|
||||||
|
// If collect fails, return error
|
||||||
|
// If collect succeeds, parse the output and update the SmartDataMap
|
||||||
|
func (sm *SmartManager) CollectSmart(deviceInfo *DeviceInfo) error {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
cmd := exec.CommandContext(ctx, "smartctl", "--all", "-j", deviceInfo.Name)
|
||||||
|
|
||||||
|
output, err := cmd.Output()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
hasValidData := false
|
||||||
|
if deviceInfo.Type == "scsi" {
|
||||||
|
// parse scsi devices
|
||||||
|
hasValidData = sm.parseSmartForScsi(output)
|
||||||
|
} else if deviceInfo.Type == "nvme" {
|
||||||
|
// parse nvme devices
|
||||||
|
hasValidData = sm.parseSmartForNvme(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasValidData {
|
||||||
|
return errNoValidSmartData
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseScan parses the output of smartctl --scan -j and updates the SmartDevices slice
|
||||||
|
func (sm *SmartManager) parseScan(output []byte) bool {
|
||||||
|
sm.mutex.Lock()
|
||||||
|
defer sm.mutex.Unlock()
|
||||||
|
|
||||||
|
sm.SmartDevices = make([]*DeviceInfo, 0)
|
||||||
|
scan := &scanOutput{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, scan); err != nil {
|
||||||
|
fmt.Printf("Failed to parse JSON: %v\n", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
scannedDeviceNameMap := make(map[string]bool)
|
||||||
|
|
||||||
|
for _, device := range scan.Devices {
|
||||||
|
deviceInfo := &DeviceInfo{
|
||||||
|
Name: device.Name,
|
||||||
|
Type: device.Type,
|
||||||
|
InfoName: device.InfoName,
|
||||||
|
Protocol: device.Protocol,
|
||||||
|
}
|
||||||
|
sm.SmartDevices = append(sm.SmartDevices, deviceInfo)
|
||||||
|
scannedDeviceNameMap[device.Name] = true
|
||||||
|
}
|
||||||
|
// remove devices that are not in the scan
|
||||||
|
for key := range sm.SmartDataMap {
|
||||||
|
if _, ok := scannedDeviceNameMap[key]; !ok {
|
||||||
|
delete(sm.SmartDataMap, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
devicesString := ""
|
||||||
|
for _, device := range sm.SmartDevices {
|
||||||
|
devicesString += device.Name + " "
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSmartForScsi parses the output of smartctl --all -j /dev/sdX and updates the SmartDataMap
|
||||||
|
func (sm *SmartManager) parseSmartForScsi(output []byte) bool {
|
||||||
|
data := &smart.SmartInfoForSata{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.mutex.Lock()
|
||||||
|
defer sm.mutex.Unlock()
|
||||||
|
|
||||||
|
// get device name (e.g. /dev/sda)
|
||||||
|
keyName := data.SerialNumber
|
||||||
|
|
||||||
|
// if device does not exist in SmartDataMap, initialize it
|
||||||
|
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
||||||
|
sm.SmartDataMap[keyName] = &system.SmartData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update SmartData
|
||||||
|
smartData := sm.SmartDataMap[keyName]
|
||||||
|
smartData.ModelFamily = data.ModelFamily
|
||||||
|
smartData.ModelName = data.ModelName
|
||||||
|
smartData.SerialNumber = data.SerialNumber
|
||||||
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
|
if data.SmartStatus.Passed {
|
||||||
|
smartData.SmartStatus = "PASSED"
|
||||||
|
} else {
|
||||||
|
smartData.SmartStatus = "FAILED"
|
||||||
|
}
|
||||||
|
smartData.DiskName = data.Device.Name
|
||||||
|
smartData.DiskType = data.Device.Type
|
||||||
|
|
||||||
|
// update SmartAttributes
|
||||||
|
smartData.Attributes = make([]*system.SmartAttribute, 0, len(data.AtaSmartAttributes.Table))
|
||||||
|
for _, attr := range data.AtaSmartAttributes.Table {
|
||||||
|
smartAttr := &system.SmartAttribute{
|
||||||
|
Id: attr.ID,
|
||||||
|
Name: attr.Name,
|
||||||
|
Value: attr.Value,
|
||||||
|
Worst: attr.Worst,
|
||||||
|
Threshold: attr.Thresh,
|
||||||
|
RawValue: attr.Raw.Value,
|
||||||
|
RawString: attr.Raw.String,
|
||||||
|
Flags: attr.Flags.String,
|
||||||
|
WhenFailed: attr.WhenFailed,
|
||||||
|
}
|
||||||
|
smartData.Attributes = append(smartData.Attributes, smartAttr)
|
||||||
|
}
|
||||||
|
smartData.Temperature = data.Temperature.Current
|
||||||
|
sm.SmartDataMap[keyName] = smartData
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||||
|
func (sm *SmartManager) parseSmartForNvme(output []byte) bool {
|
||||||
|
data := &smart.SmartInfoForNvme{}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(output, &data); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.mutex.Lock()
|
||||||
|
defer sm.mutex.Unlock()
|
||||||
|
|
||||||
|
// get device name (e.g. /dev/nvme0)
|
||||||
|
keyName := data.SerialNumber
|
||||||
|
|
||||||
|
// if device does not exist in SmartDataMap, initialize it
|
||||||
|
if _, ok := sm.SmartDataMap[keyName]; !ok {
|
||||||
|
sm.SmartDataMap[keyName] = &system.SmartData{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// update SmartData
|
||||||
|
smartData := sm.SmartDataMap[keyName]
|
||||||
|
smartData.ModelName = data.ModelName
|
||||||
|
smartData.SerialNumber = data.SerialNumber
|
||||||
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
|
smartData.Capacity = data.UserCapacity.Bytes
|
||||||
|
if data.SmartStatus.Passed {
|
||||||
|
smartData.SmartStatus = "PASSED"
|
||||||
|
} else {
|
||||||
|
smartData.SmartStatus = "FAILED"
|
||||||
|
}
|
||||||
|
smartData.DiskName = data.Device.Name
|
||||||
|
smartData.DiskType = data.Device.Type
|
||||||
|
|
||||||
|
v := reflect.ValueOf(data.NVMeSmartHealthInformationLog)
|
||||||
|
t := v.Type()
|
||||||
|
smartData.Attributes = make([]*system.SmartAttribute, 0, v.NumField())
|
||||||
|
|
||||||
|
// nvme attributes does not follow the same format as ata attributes,
|
||||||
|
// so we have to manually iterate over the fields and update SmartAttributes
|
||||||
|
for i := 0; i < v.NumField(); i++ {
|
||||||
|
field := t.Field(i)
|
||||||
|
value := v.Field(i)
|
||||||
|
key := field.Name
|
||||||
|
val := value.Interface()
|
||||||
|
// drop non int values
|
||||||
|
if _, ok := val.(int); !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
smartAttr := &system.SmartAttribute{
|
||||||
|
Name: key,
|
||||||
|
RawValue: val.(int),
|
||||||
|
}
|
||||||
|
smartData.Attributes = append(smartData.Attributes, smartAttr)
|
||||||
|
}
|
||||||
|
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
||||||
|
|
||||||
|
sm.SmartDataMap[keyName] = smartData
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectSmartctl checks if smartctl is installed, returns an error if not
|
||||||
|
func (sm *SmartManager) detectSmartctl() error {
|
||||||
|
if _, err := exec.LookPath("smartctl"); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no smartctl found - install smartctl")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGPUManager creates and initializes a new GPUManager
|
||||||
|
func NewSmartManager() (*SmartManager, error) {
|
||||||
|
var sm SmartManager
|
||||||
|
if err := sm.detectSmartctl(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
go sm.Start()
|
||||||
|
|
||||||
|
return &sm, nil
|
||||||
|
}
|
||||||
@@ -237,6 +237,17 @@ func (a *Agent) getSystemStats() system.Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if a.smartManager != nil {
|
||||||
|
if smartData := a.smartManager.GetCurrentData(); len(smartData) > 0 {
|
||||||
|
systemStats.SmartData = smartData
|
||||||
|
if systemStats.Temperatures == nil {
|
||||||
|
systemStats.Temperatures = make(map[string]float64, len(a.smartManager.SmartDataMap))
|
||||||
|
}
|
||||||
|
for key, value := range a.smartManager.SmartDataMap {
|
||||||
|
systemStats.Temperatures[key] = float64(value.Temperature)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// update base system info
|
// update base system info
|
||||||
a.systemInfo.Cpu = systemStats.Cpu
|
a.systemInfo.Cpu = systemStats.Cpu
|
||||||
|
|||||||
@@ -15,8 +15,13 @@ import (
|
|||||||
"github.com/pocketbase/pocketbase/tools/mailer"
|
"github.com/pocketbase/pocketbase/tools/mailer"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type hubLike interface {
|
||||||
|
core.App
|
||||||
|
MakeLink(parts ...string) string
|
||||||
|
}
|
||||||
|
|
||||||
type AlertManager struct {
|
type AlertManager struct {
|
||||||
app core.App
|
hub hubLike
|
||||||
alertQueue chan alertTask
|
alertQueue chan alertTask
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
pendingAlerts sync.Map
|
pendingAlerts sync.Map
|
||||||
@@ -79,9 +84,9 @@ var supportsTitle = map[string]struct{}{
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NewAlertManager creates a new AlertManager instance.
|
// NewAlertManager creates a new AlertManager instance.
|
||||||
func NewAlertManager(app core.App) *AlertManager {
|
func NewAlertManager(app hubLike) *AlertManager {
|
||||||
am := &AlertManager{
|
am := &AlertManager{
|
||||||
app: app,
|
hub: app,
|
||||||
alertQueue: make(chan alertTask),
|
alertQueue: make(chan alertTask),
|
||||||
stopChan: make(chan struct{}),
|
stopChan: make(chan struct{}),
|
||||||
}
|
}
|
||||||
@@ -91,7 +96,7 @@ func NewAlertManager(app core.App) *AlertManager {
|
|||||||
|
|
||||||
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
||||||
// get user settings
|
// get user settings
|
||||||
record, err := am.app.FindFirstRecordByFilter(
|
record, err := am.hub.FindFirstRecordByFilter(
|
||||||
"user_settings", "user={:user}",
|
"user_settings", "user={:user}",
|
||||||
dbx.Params{"user": data.UserID},
|
dbx.Params{"user": data.UserID},
|
||||||
)
|
)
|
||||||
@@ -104,12 +109,12 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
Webhooks: []string{},
|
Webhooks: []string{},
|
||||||
}
|
}
|
||||||
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
|
if err := record.UnmarshalJSONField("settings", &userAlertSettings); err != nil {
|
||||||
am.app.Logger().Error("Failed to unmarshal user settings", "err", err.Error())
|
am.hub.Logger().Error("Failed to unmarshal user settings", "err", err)
|
||||||
}
|
}
|
||||||
// send alerts via webhooks
|
// send alerts via webhooks
|
||||||
for _, webhook := range userAlertSettings.Webhooks {
|
for _, webhook := range userAlertSettings.Webhooks {
|
||||||
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
if err := am.SendShoutrrrAlert(webhook, data.Title, data.Message, data.Link, data.LinkText); err != nil {
|
||||||
am.app.Logger().Error("Failed to send shoutrrr alert", "err", err.Error())
|
am.hub.Logger().Error("Failed to send shoutrrr alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// send alerts via email
|
// send alerts via email
|
||||||
@@ -125,15 +130,15 @@ func (am *AlertManager) SendAlert(data AlertMessageData) error {
|
|||||||
Subject: data.Title,
|
Subject: data.Title,
|
||||||
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
|
Text: data.Message + fmt.Sprintf("\n\n%s", data.Link),
|
||||||
From: mail.Address{
|
From: mail.Address{
|
||||||
Address: am.app.Settings().Meta.SenderAddress,
|
Address: am.hub.Settings().Meta.SenderAddress,
|
||||||
Name: am.app.Settings().Meta.SenderName,
|
Name: am.hub.Settings().Meta.SenderName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err = am.app.NewMailClient().Send(&message)
|
err = am.hub.NewMailClient().Send(&message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
am.app.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
am.hub.Logger().Info("Sent email alert", "to", message.To, "subj", message.Subject)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,9 +188,9 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
err = shoutrrr.Send(parsedURL.String(), message)
|
err = shoutrrr.Send(parsedURL.String(), message)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
am.app.Logger().Info("Sent shoutrrr alert", "title", title)
|
am.hub.Logger().Info("Sent shoutrrr alert", "title", title)
|
||||||
} else {
|
} else {
|
||||||
am.app.Logger().Error("Error sending shoutrrr alert", "err", err.Error())
|
am.hub.Logger().Error("Error sending shoutrrr alert", "err", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -201,7 +206,7 @@ func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
|||||||
if url == "" {
|
if url == "" {
|
||||||
return e.JSON(200, map[string]string{"err": "URL is required"})
|
return e.JSON(200, map[string]string{"err": "URL is required"})
|
||||||
}
|
}
|
||||||
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.app.Settings().Meta.AppURL, "View Beszel")
|
err := am.SendShoutrrrAlert(url, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package alerts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -87,7 +86,7 @@ func (am *AlertManager) HandleStatusAlerts(newStatus string, systemRecord *core.
|
|||||||
|
|
||||||
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
// getSystemStatusAlerts retrieves all "Status" alert records for a given system ID.
|
||||||
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
func (am *AlertManager) getSystemStatusAlerts(systemID string) ([]*core.Record, error) {
|
||||||
alertRecords, err := am.app.FindAllRecords("alerts", dbx.HashExp{
|
alertRecords, err := am.hub.FindAllRecords("alerts", dbx.HashExp{
|
||||||
"system": systemID,
|
"system": systemID,
|
||||||
"name": "Status",
|
"name": "Status",
|
||||||
})
|
})
|
||||||
@@ -130,7 +129,7 @@ func (am *AlertManager) handleSystemUp(systemName string, alertRecords []*core.R
|
|||||||
}
|
}
|
||||||
// No alert scheduled for this record, send "up" alert
|
// No alert scheduled for this record, send "up" alert
|
||||||
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
if err := am.sendStatusAlert("up", systemName, alertRecord); err != nil {
|
||||||
am.app.Logger().Error("Failed to send alert", "err", err.Error())
|
am.hub.Logger().Error("Failed to send alert", "err", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +146,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
title := fmt.Sprintf("Connection to %s is %s %v", systemName, alertStatus, emoji)
|
||||||
message := strings.TrimSuffix(title, emoji)
|
message := strings.TrimSuffix(title, emoji)
|
||||||
|
|
||||||
if errs := am.app.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
if errs := am.hub.ExpandRecord(alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
return errs["user"]
|
return errs["user"]
|
||||||
}
|
}
|
||||||
user := alertRecord.ExpandedOne("user")
|
user := alertRecord.ExpandedOne("user")
|
||||||
@@ -159,7 +158,7 @@ func (am *AlertManager) sendStatusAlert(alertStatus string, systemName string, a
|
|||||||
UserID: user.Id,
|
UserID: user.Id,
|
||||||
Title: title,
|
Title: title,
|
||||||
Message: message,
|
Message: message,
|
||||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package alerts
|
|||||||
import (
|
import (
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error {
|
||||||
alertRecords, err := am.app.FindAllRecords("alerts",
|
alertRecords, err := am.hub.FindAllRecords("alerts",
|
||||||
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
dbx.NewExp("system={:system}", dbx.Params{"system": systemRecord.Id}),
|
||||||
)
|
)
|
||||||
if err != nil || len(alertRecords) == 0 {
|
if err != nil || len(alertRecords) == 0 {
|
||||||
@@ -101,7 +100,7 @@ func (am *AlertManager) HandleSystemAlerts(systemRecord *core.Record, data *syst
|
|||||||
Created types.DateTime `db:"created"`
|
Created types.DateTime `db:"created"`
|
||||||
}{}
|
}{}
|
||||||
|
|
||||||
err = am.app.DB().
|
err = am.hub.DB().
|
||||||
Select("stats", "created").
|
Select("stats", "created").
|
||||||
From("system_stats").
|
From("system_stats").
|
||||||
Where(dbx.NewExp(
|
Where(dbx.NewExp(
|
||||||
@@ -271,12 +270,12 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
body := fmt.Sprintf("%s averaged %.2f%s for the previous %v %s.", alert.descriptor, alert.val, alert.unit, alert.min, minutesLabel)
|
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)
|
alert.alertRecord.Set("triggered", alert.triggered)
|
||||||
if err := am.app.Save(alert.alertRecord); err != nil {
|
if err := am.hub.Save(alert.alertRecord); err != nil {
|
||||||
// app.Logger().Error("failed to save alert record", "err", err.Error())
|
// app.Logger().Error("failed to save alert record", "err", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// expand the user relation and send the alert
|
// expand the user relation and send the alert
|
||||||
if errs := am.app.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
if errs := am.hub.ExpandRecord(alert.alertRecord, []string{"user"}, nil); len(errs) > 0 {
|
||||||
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
// app.Logger().Error("failed to expand user relation", "errs", errs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -285,7 +284,7 @@ func (am *AlertManager) sendSystemAlert(alert SystemAlertData) {
|
|||||||
UserID: user.Id,
|
UserID: user.Id,
|
||||||
Title: subject,
|
Title: subject,
|
||||||
Message: body,
|
Message: body,
|
||||||
Link: am.app.Settings().Meta.AppURL + "/system/" + url.PathEscape(systemName),
|
Link: am.hub.MakeLink("system", systemName),
|
||||||
LinkText: "View " + systemName,
|
LinkText: "View " + systemName,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
7
beszel/internal/common/common.go
Normal file
7
beszel/internal/common/common.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
var (
|
||||||
|
DefaultKeyExchanges = []string{"curve25519-sha256"}
|
||||||
|
DefaultMACs = []string{"hmac-sha2-256-etm@openssh.com"}
|
||||||
|
DefaultCiphers = []string{"chacha20-poly1305@openssh.com"}
|
||||||
|
)
|
||||||
269
beszel/internal/entities/smart/smart.go
Normal file
269
beszel/internal/entities/smart/smart.go
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
package smart
|
||||||
|
|
||||||
|
type SmartInfoForSata struct {
|
||||||
|
JSONFormatVersion []int `json:"json_format_version"`
|
||||||
|
Smartctl struct {
|
||||||
|
Version []int `json:"version"`
|
||||||
|
SvnRevision string `json:"svn_revision"`
|
||||||
|
PlatformInfo string `json:"platform_info"`
|
||||||
|
BuildInfo string `json:"build_info"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
ExitStatus int `json:"exit_status"`
|
||||||
|
} `json:"smartctl"`
|
||||||
|
Device struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
InfoName string `json:"info_name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
} `json:"device"`
|
||||||
|
ModelFamily string `json:"model_family"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
Wwn struct {
|
||||||
|
Naa int `json:"naa"`
|
||||||
|
Oui int `json:"oui"`
|
||||||
|
ID int `json:"id"`
|
||||||
|
} `json:"wwn"`
|
||||||
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
|
UserCapacity struct {
|
||||||
|
Blocks uint64 `json:"blocks"`
|
||||||
|
Bytes uint64 `json:"bytes"`
|
||||||
|
} `json:"user_capacity"`
|
||||||
|
LogicalBlockSize int `json:"logical_block_size"`
|
||||||
|
PhysicalBlockSize int `json:"physical_block_size"`
|
||||||
|
RotationRate int `json:"rotation_rate"`
|
||||||
|
FormFactor struct {
|
||||||
|
AtaValue int `json:"ata_value"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"form_factor"`
|
||||||
|
Trim struct {
|
||||||
|
Supported bool `json:"supported"`
|
||||||
|
} `json:"trim"`
|
||||||
|
InSmartctlDatabase bool `json:"in_smartctl_database"`
|
||||||
|
AtaVersion struct {
|
||||||
|
String string `json:"string"`
|
||||||
|
MajorValue int `json:"major_value"`
|
||||||
|
MinorValue int `json:"minor_value"`
|
||||||
|
} `json:"ata_version"`
|
||||||
|
SataVersion struct {
|
||||||
|
String string `json:"string"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
} `json:"sata_version"`
|
||||||
|
InterfaceSpeed struct {
|
||||||
|
Max struct {
|
||||||
|
SataValue int `json:"sata_value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
UnitsPerSecond int `json:"units_per_second"`
|
||||||
|
BitsPerUnit int `json:"bits_per_unit"`
|
||||||
|
} `json:"max"`
|
||||||
|
Current struct {
|
||||||
|
SataValue int `json:"sata_value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
UnitsPerSecond int `json:"units_per_second"`
|
||||||
|
BitsPerUnit int `json:"bits_per_unit"`
|
||||||
|
} `json:"current"`
|
||||||
|
} `json:"interface_speed"`
|
||||||
|
LocalTime struct {
|
||||||
|
TimeT int `json:"time_t"`
|
||||||
|
Asctime string `json:"asctime"`
|
||||||
|
} `json:"local_time"`
|
||||||
|
SmartStatus struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
} `json:"smart_status"`
|
||||||
|
AtaSmartData struct {
|
||||||
|
OfflineDataCollection struct {
|
||||||
|
Status struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
} `json:"status"`
|
||||||
|
CompletionSeconds int `json:"completion_seconds"`
|
||||||
|
} `json:"offline_data_collection"`
|
||||||
|
SelfTest struct {
|
||||||
|
Status struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
} `json:"status"`
|
||||||
|
PollingMinutes struct {
|
||||||
|
Short int `json:"short"`
|
||||||
|
Extended int `json:"extended"`
|
||||||
|
} `json:"polling_minutes"`
|
||||||
|
} `json:"self_test"`
|
||||||
|
Capabilities struct {
|
||||||
|
Values []int `json:"values"`
|
||||||
|
ExecOfflineImmediateSupported bool `json:"exec_offline_immediate_supported"`
|
||||||
|
OfflineIsAbortedUponNewCmd bool `json:"offline_is_aborted_upon_new_cmd"`
|
||||||
|
OfflineSurfaceScanSupported bool `json:"offline_surface_scan_supported"`
|
||||||
|
SelfTestsSupported bool `json:"self_tests_supported"`
|
||||||
|
ConveyanceSelfTestSupported bool `json:"conveyance_self_test_supported"`
|
||||||
|
SelectiveSelfTestSupported bool `json:"selective_self_test_supported"`
|
||||||
|
AttributeAutosaveEnabled bool `json:"attribute_autosave_enabled"`
|
||||||
|
ErrorLoggingSupported bool `json:"error_logging_supported"`
|
||||||
|
GpLoggingSupported bool `json:"gp_logging_supported"`
|
||||||
|
} `json:"capabilities"`
|
||||||
|
} `json:"ata_smart_data"`
|
||||||
|
AtaSctCapabilities struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
ErrorRecoveryControlSupported bool `json:"error_recovery_control_supported"`
|
||||||
|
FeatureControlSupported bool `json:"feature_control_supported"`
|
||||||
|
DataTableSupported bool `json:"data_table_supported"`
|
||||||
|
} `json:"ata_sct_capabilities"`
|
||||||
|
AtaSmartAttributes struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Table []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
Worst int `json:"worst"`
|
||||||
|
Thresh int `json:"thresh"`
|
||||||
|
WhenFailed string `json:"when_failed"`
|
||||||
|
Flags struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
Prefailure bool `json:"prefailure"`
|
||||||
|
UpdatedOnline bool `json:"updated_online"`
|
||||||
|
Performance bool `json:"performance"`
|
||||||
|
ErrorRate bool `json:"error_rate"`
|
||||||
|
EventCount bool `json:"event_count"`
|
||||||
|
AutoKeep bool `json:"auto_keep"`
|
||||||
|
} `json:"flags"`
|
||||||
|
Raw struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
} `json:"raw"`
|
||||||
|
} `json:"table"`
|
||||||
|
} `json:"ata_smart_attributes"`
|
||||||
|
PowerOnTime struct {
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
} `json:"power_on_time"`
|
||||||
|
PowerCycleCount int `json:"power_cycle_count"`
|
||||||
|
Temperature struct {
|
||||||
|
Current int `json:"current"`
|
||||||
|
} `json:"temperature"`
|
||||||
|
AtaSmartErrorLog struct {
|
||||||
|
Summary struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"summary"`
|
||||||
|
} `json:"ata_smart_error_log"`
|
||||||
|
AtaSmartSelfTestLog struct {
|
||||||
|
Standard struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
} `json:"standard"`
|
||||||
|
} `json:"ata_smart_self_test_log"`
|
||||||
|
AtaSmartSelectiveSelfTestLog struct {
|
||||||
|
Revision int `json:"revision"`
|
||||||
|
Table []struct {
|
||||||
|
LbaMin int `json:"lba_min"`
|
||||||
|
LbaMax int `json:"lba_max"`
|
||||||
|
Status struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
String string `json:"string"`
|
||||||
|
} `json:"status"`
|
||||||
|
} `json:"table"`
|
||||||
|
Flags struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
RemainderScanEnabled bool `json:"remainder_scan_enabled"`
|
||||||
|
} `json:"flags"`
|
||||||
|
PowerUpScanResumeMinutes int `json:"power_up_scan_resume_minutes"`
|
||||||
|
} `json:"ata_smart_selective_self_test_log"`
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type SmartInfoForNvme struct {
|
||||||
|
JSONFormatVersion [2]int `json:"json_format_version"`
|
||||||
|
Smartctl struct {
|
||||||
|
Version [2]int `json:"version"`
|
||||||
|
SVNRevision string `json:"svn_revision"`
|
||||||
|
PlatformInfo string `json:"platform_info"`
|
||||||
|
BuildInfo string `json:"build_info"`
|
||||||
|
Argv []string `json:"argv"`
|
||||||
|
ExitStatus int `json:"exit_status"`
|
||||||
|
} `json:"smartctl"`
|
||||||
|
Device struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
InfoName string `json:"info_name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
} `json:"device"`
|
||||||
|
ModelName string `json:"model_name"`
|
||||||
|
SerialNumber string `json:"serial_number"`
|
||||||
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
|
NVMePCIVendor struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
SubsystemID int `json:"subsystem_id"`
|
||||||
|
} `json:"nvme_pci_vendor"`
|
||||||
|
NVMeIEEEOUIIdentifier int `json:"nvme_ieee_oui_identifier"`
|
||||||
|
NVMeTotalCapacity int `json:"nvme_total_capacity"`
|
||||||
|
NVMeUnallocatedCapacity int `json:"nvme_unallocated_capacity"`
|
||||||
|
NVMeControllerID int `json:"nvme_controller_id"`
|
||||||
|
NVMeVersion struct {
|
||||||
|
String string `json:"string"`
|
||||||
|
Value int `json:"value"`
|
||||||
|
} `json:"nvme_version"`
|
||||||
|
NVMeNumberOfNamespaces int `json:"nvme_number_of_namespaces"`
|
||||||
|
NVMeNamespaces []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Size struct {
|
||||||
|
Blocks int `json:"blocks"`
|
||||||
|
Bytes int `json:"bytes"`
|
||||||
|
} `json:"size"`
|
||||||
|
Capacity struct {
|
||||||
|
Blocks int `json:"blocks"`
|
||||||
|
Bytes int `json:"bytes"`
|
||||||
|
} `json:"capacity"`
|
||||||
|
Utilization struct {
|
||||||
|
Blocks int `json:"blocks"`
|
||||||
|
Bytes int `json:"bytes"`
|
||||||
|
} `json:"utilization"`
|
||||||
|
FormattedLBASize int `json:"formatted_lba_size"`
|
||||||
|
EUI64 struct {
|
||||||
|
OUI int `json:"oui"`
|
||||||
|
ExtID int `json:"ext_id"`
|
||||||
|
} `json:"eui64"`
|
||||||
|
} `json:"nvme_namespaces"`
|
||||||
|
UserCapacity struct {
|
||||||
|
Blocks uint64 `json:"blocks"`
|
||||||
|
Bytes uint64 `json:"bytes"`
|
||||||
|
} `json:"user_capacity"`
|
||||||
|
LogicalBlockSize int `json:"logical_block_size"`
|
||||||
|
LocalTime struct {
|
||||||
|
TimeT int64 `json:"time_t"`
|
||||||
|
Asctime string `json:"asctime"`
|
||||||
|
} `json:"local_time"`
|
||||||
|
SmartStatus struct {
|
||||||
|
Passed bool `json:"passed"`
|
||||||
|
NVMe struct {
|
||||||
|
Value int `json:"value"`
|
||||||
|
} `json:"nvme"`
|
||||||
|
} `json:"smart_status"`
|
||||||
|
NVMeSmartHealthInformationLog struct {
|
||||||
|
CriticalWarning int `json:"critical_warning"`
|
||||||
|
Temperature int `json:"temperature"`
|
||||||
|
AvailableSpare int `json:"available_spare"`
|
||||||
|
AvailableSpareThreshold int `json:"available_spare_threshold"`
|
||||||
|
PercentageUsed int `json:"percentage_used"`
|
||||||
|
DataUnitsRead int `json:"data_units_read"`
|
||||||
|
DataUnitsWritten int `json:"data_units_written"`
|
||||||
|
HostReads int `json:"host_reads"`
|
||||||
|
HostWrites int `json:"host_writes"`
|
||||||
|
ControllerBusyTime int `json:"controller_busy_time"`
|
||||||
|
PowerCycles int `json:"power_cycles"`
|
||||||
|
PowerOnHours int `json:"power_on_hours"`
|
||||||
|
UnsafeShutdowns int `json:"unsafe_shutdowns"`
|
||||||
|
MediaErrors int `json:"media_errors"`
|
||||||
|
NumErrLogEntries int `json:"num_err_log_entries"`
|
||||||
|
WarningTempTime int `json:"warning_temp_time"`
|
||||||
|
CriticalCompTime int `json:"critical_comp_time"`
|
||||||
|
TemperatureSensors []int `json:"temperature_sensors"`
|
||||||
|
} `json:"nvme_smart_health_information_log"`
|
||||||
|
Temperature struct {
|
||||||
|
Current int `json:"current"`
|
||||||
|
} `json:"temperature"`
|
||||||
|
PowerCycleCount int `json:"power_cycle_count"`
|
||||||
|
PowerOnTime struct {
|
||||||
|
Hours int `json:"hours"`
|
||||||
|
} `json:"power_on_time"`
|
||||||
|
}
|
||||||
@@ -8,29 +8,30 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Stats struct {
|
type Stats struct {
|
||||||
Cpu float64 `json:"cpu"`
|
Cpu float64 `json:"cpu"`
|
||||||
MaxCpu float64 `json:"cpum,omitempty"`
|
MaxCpu float64 `json:"cpum,omitempty"`
|
||||||
Mem float64 `json:"m"`
|
Mem float64 `json:"m"`
|
||||||
MemUsed float64 `json:"mu"`
|
MemUsed float64 `json:"mu"`
|
||||||
MemPct float64 `json:"mp"`
|
MemPct float64 `json:"mp"`
|
||||||
MemBuffCache float64 `json:"mb"`
|
MemBuffCache float64 `json:"mb"`
|
||||||
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
MemZfsArc float64 `json:"mz,omitempty"` // ZFS ARC memory
|
||||||
Swap float64 `json:"s,omitempty"`
|
Swap float64 `json:"s,omitempty"`
|
||||||
SwapUsed float64 `json:"su,omitempty"`
|
SwapUsed float64 `json:"su,omitempty"`
|
||||||
DiskTotal float64 `json:"d"`
|
DiskTotal float64 `json:"d"`
|
||||||
DiskUsed float64 `json:"du"`
|
DiskUsed float64 `json:"du"`
|
||||||
DiskPct float64 `json:"dp"`
|
DiskPct float64 `json:"dp"`
|
||||||
DiskReadPs float64 `json:"dr"`
|
DiskReadPs float64 `json:"dr"`
|
||||||
DiskWritePs float64 `json:"dw"`
|
DiskWritePs float64 `json:"dw"`
|
||||||
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
MaxDiskReadPs float64 `json:"drm,omitempty"`
|
||||||
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
MaxDiskWritePs float64 `json:"dwm,omitempty"`
|
||||||
NetworkSent float64 `json:"ns"`
|
NetworkSent float64 `json:"ns"`
|
||||||
NetworkRecv float64 `json:"nr"`
|
NetworkRecv float64 `json:"nr"`
|
||||||
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
MaxNetworkSent float64 `json:"nsm,omitempty"`
|
||||||
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
MaxNetworkRecv float64 `json:"nrm,omitempty"`
|
||||||
Temperatures map[string]float64 `json:"t,omitempty"`
|
Temperatures map[string]float64 `json:"t,omitempty"`
|
||||||
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
ExtraFs map[string]*FsStats `json:"efs,omitempty"`
|
||||||
GPUData map[string]GPUData `json:"g,omitempty"`
|
GPUData map[string]GPUData `json:"g,omitempty"`
|
||||||
|
SmartData map[string]SmartData `json:"sm,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type GPUData struct {
|
type GPUData struct {
|
||||||
@@ -73,6 +74,31 @@ const (
|
|||||||
Freebsd
|
Freebsd
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type SmartData struct {
|
||||||
|
ModelFamily string `json:"mf,omitempty"`
|
||||||
|
ModelName string `json:"mn,omitempty"`
|
||||||
|
SerialNumber string `json:"sn,omitempty"`
|
||||||
|
FirmwareVersion string `json:"fv,omitempty"`
|
||||||
|
Capacity uint64 `json:"c,omitempty"`
|
||||||
|
SmartStatus string `json:"s,omitempty"`
|
||||||
|
DiskName string `json:"dn,omitempty"` // something like /dev/sda
|
||||||
|
DiskType string `json:"dt,omitempty"`
|
||||||
|
Temperature int `json:"t,omitempty"`
|
||||||
|
Attributes []*SmartAttribute `json:"a,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SmartAttribute struct {
|
||||||
|
Id int `json:"id,omitempty"`
|
||||||
|
Name string `json:"n"`
|
||||||
|
Value int `json:"v,omitempty"`
|
||||||
|
Worst int `json:"w,omitempty"`
|
||||||
|
Threshold int `json:"t,omitempty"`
|
||||||
|
RawValue int `json:"rv"`
|
||||||
|
RawString string `json:"rs,omitempty"`
|
||||||
|
Flags string `json:"f,omitempty"`
|
||||||
|
WhenFailed string `json:"wf,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Info struct {
|
type Info struct {
|
||||||
Hostname string `json:"h"`
|
Hostname string `json:"h"`
|
||||||
KernelVersion string `json:"k,omitempty"`
|
KernelVersion string `json:"k,omitempty"`
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import (
|
|||||||
"beszel/site"
|
"beszel/site"
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase"
|
"github.com/pocketbase/pocketbase"
|
||||||
@@ -56,7 +58,6 @@ func GetEnv(key string) (value string, exists bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *Hub) StartHub() error {
|
func (h *Hub) StartHub() error {
|
||||||
|
|
||||||
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
h.App.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
||||||
// initialize settings / collections
|
// initialize settings / collections
|
||||||
if err := h.initialize(e); err != nil {
|
if err := h.initialize(e); err != nil {
|
||||||
@@ -156,7 +157,7 @@ func (h *Hub) initialize(e *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startServer starts the server for the Beszel (not PocketBase)
|
// startServer sets up the server for Beszel
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
// TODO: exclude dev server from production binary
|
// TODO: exclude dev server from production binary
|
||||||
switch h.IsDev() {
|
switch h.IsDev() {
|
||||||
@@ -239,73 +240,63 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generates key pair if it doesn't exist and returns private key bytes
|
// generates key pair if it doesn't exist and returns signer
|
||||||
func (h *Hub) GetSSHKey() ([]byte, error) {
|
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
|
||||||
dataDir := h.DataDir()
|
privateKeyPath := path.Join(dataDir, "id_ed25519")
|
||||||
|
|
||||||
// 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(privateKeyPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
if pubKey, err := os.ReadFile(h.DataDir() + "/id_ed25519.pub"); err == nil {
|
private, err := ssh.ParsePrivateKey(existingKey)
|
||||||
h.pubKey = strings.TrimSuffix(string(pubKey), "\n")
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse private key: %s", err)
|
||||||
}
|
}
|
||||||
// return existing private key
|
pubKeyBytes := ssh.MarshalAuthorizedKey(private.PublicKey())
|
||||||
return existingKey, nil
|
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
||||||
|
return private, nil
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
// File exists but couldn't be read for some other reason
|
||||||
|
return nil, fmt.Errorf("failed to read %s: %w", privateKeyPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate the Ed25519 key pair
|
// Generate the Ed25519 key pair
|
||||||
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
pubKey, privKey, err := ed25519.GenerateKey(nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// h.Logger().Error("Error generating key pair:", "err", err.Error())
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the private key in OpenSSH format
|
// Get the private key in OpenSSH format
|
||||||
privKeyBytes, err := ssh.MarshalPrivateKey(privKey, "")
|
privKeyPem, err := ssh.MarshalPrivateKey(privKey, "")
|
||||||
if err != nil {
|
|
||||||
// h.Logger().Error("Error marshaling private key:", "err", err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save the private key to a file
|
|
||||||
privateFile, err := os.Create(dataDir + "/id_ed25519")
|
|
||||||
if err != nil {
|
|
||||||
// h.Logger().Error("Error creating private key file:", "err", err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer privateFile.Close()
|
|
||||||
|
|
||||||
if err := pem.Encode(privateFile, privKeyBytes); err != nil {
|
|
||||||
// h.Logger().Error("Error writing private key to file:", "err", err.Error())
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the public key in OpenSSH format
|
|
||||||
publicKey, err := ssh.NewPublicKey(pubKey)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
pubKeyBytes := ssh.MarshalAuthorizedKey(publicKey)
|
if err := os.WriteFile(privateKeyPath, pem.EncodeToMemory(privKeyPem), 0600); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to write private key to %q: err: %w", privateKeyPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// These are fine to ignore the errors on, as we've literally just created a crypto.PublicKey | crypto.Signer
|
||||||
|
sshPubKey, _ := ssh.NewPublicKey(pubKey)
|
||||||
|
sshPrivate, _ := ssh.NewSignerFromSigner(privKey)
|
||||||
|
|
||||||
|
pubKeyBytes := ssh.MarshalAuthorizedKey(sshPubKey)
|
||||||
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
h.pubKey = strings.TrimSuffix(string(pubKeyBytes), "\n")
|
||||||
|
|
||||||
// Save the public key to a file
|
|
||||||
publicFile, err := os.Create(dataDir + "/id_ed25519.pub")
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer publicFile.Close()
|
|
||||||
|
|
||||||
if _, err := publicFile.Write(pubKeyBytes); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
h.Logger().Info("ed25519 SSH key pair generated successfully.")
|
h.Logger().Info("ed25519 SSH key pair generated successfully.")
|
||||||
h.Logger().Info("Private key saved to: " + dataDir + "/id_ed25519")
|
h.Logger().Info("Saved to: " + privateKeyPath)
|
||||||
h.Logger().Info("Public key saved to: " + dataDir + "/id_ed25519.pub")
|
|
||||||
|
|
||||||
existingKey, err = os.ReadFile(dataDir + "/id_ed25519")
|
return sshPrivate, err
|
||||||
if err == nil {
|
}
|
||||||
return existingKey, nil
|
|
||||||
}
|
// MakeLink formats a link with the app URL and path segments.
|
||||||
return nil, err
|
// Only path segments should be provided.
|
||||||
|
func (h *Hub) MakeLink(parts ...string) string {
|
||||||
|
base := strings.TrimSuffix(h.Settings().Meta.AppURL, "/")
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
base = fmt.Sprintf("%s/%s", base, url.PathEscape(part))
|
||||||
|
}
|
||||||
|
return base
|
||||||
}
|
}
|
||||||
|
|||||||
257
beszel/internal/hub/hub_test.go
Normal file
257
beszel/internal/hub/hub_test.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
//go:build testing
|
||||||
|
// +build testing
|
||||||
|
|
||||||
|
package hub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"crypto/ed25519"
|
||||||
|
"encoding/pem"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pocketbase/pocketbase"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
func getTestHub() *Hub {
|
||||||
|
app := pocketbase.New()
|
||||||
|
return NewHub(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeLink(t *testing.T) {
|
||||||
|
hub := getTestHub()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
appURL string
|
||||||
|
parts []string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no parts, no trailing slash in AppURL",
|
||||||
|
appURL: "http://localhost:8090",
|
||||||
|
parts: []string{},
|
||||||
|
expected: "http://localhost:8090",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no parts, with trailing slash in AppURL",
|
||||||
|
appURL: "http://localhost:8090/",
|
||||||
|
parts: []string{},
|
||||||
|
expected: "http://localhost:8090", // TrimSuffix should handle the trailing slash
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one part",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"one"},
|
||||||
|
expected: "http://example.com/one",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple parts",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"alpha", "beta", "gamma"},
|
||||||
|
expected: "http://example.com/alpha/beta/gamma",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parts with spaces needing escaping",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"path with spaces", "another part"},
|
||||||
|
expected: "http://example.com/path%20with%20spaces/another%20part",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parts with slashes needing escaping",
|
||||||
|
appURL: "http://example.com",
|
||||||
|
parts: []string{"a/b", "c"},
|
||||||
|
expected: "http://example.com/a%2Fb/c", // url.PathEscape escapes '/'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppURL with subpath, no trailing slash",
|
||||||
|
appURL: "http://localhost/sub",
|
||||||
|
parts: []string{"resource"},
|
||||||
|
expected: "http://localhost/sub/resource",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "AppURL with subpath, with trailing slash",
|
||||||
|
appURL: "http://localhost/sub/",
|
||||||
|
parts: []string{"item"},
|
||||||
|
expected: "http://localhost/sub/item",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty parts in the middle",
|
||||||
|
appURL: "http://localhost",
|
||||||
|
parts: []string{"first", "", "third"},
|
||||||
|
expected: "http://localhost/first/third",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading and trailing empty parts",
|
||||||
|
appURL: "http://localhost",
|
||||||
|
parts: []string{"", "path", ""},
|
||||||
|
expected: "http://localhost/path",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parts with various special characters",
|
||||||
|
appURL: "https://test.dev/",
|
||||||
|
parts: []string{"p@th?", "key=value&"},
|
||||||
|
expected: "https://test.dev/p@th%3F/key=value&",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Store original app URL and restore it after the test
|
||||||
|
originalAppURL := hub.Settings().Meta.AppURL
|
||||||
|
hub.Settings().Meta.AppURL = tt.appURL
|
||||||
|
defer func() { hub.Settings().Meta.AppURL = originalAppURL }()
|
||||||
|
|
||||||
|
got := hub.MakeLink(tt.parts...)
|
||||||
|
assert.Equal(t, tt.expected, got, "MakeLink generated URL does not match expected")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSSHKey(t *testing.T) {
|
||||||
|
hub := getTestHub()
|
||||||
|
|
||||||
|
// Test Case 1: Key generation (no existing key)
|
||||||
|
t.Run("KeyGeneration", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Ensure pubKey is initially empty or different to ensure GetSSHKey sets it
|
||||||
|
hub.pubKey = ""
|
||||||
|
|
||||||
|
signer, err := hub.GetSSHKey(tempDir)
|
||||||
|
assert.NoError(t, err, "GetSSHKey should not error when generating a new key")
|
||||||
|
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer")
|
||||||
|
|
||||||
|
// Check if private key file was created
|
||||||
|
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
|
||||||
|
info, err := os.Stat(privateKeyPath)
|
||||||
|
assert.NoError(t, err, "Private key file should be created")
|
||||||
|
assert.False(t, info.IsDir(), "Private key path should be a file, not a directory")
|
||||||
|
|
||||||
|
// Check if h.pubKey was set
|
||||||
|
assert.NotEmpty(t, hub.pubKey, "h.pubKey should be set after key generation")
|
||||||
|
assert.True(t, strings.HasPrefix(hub.pubKey, "ssh-ed25519 "), "h.pubKey should start with 'ssh-ed25519 '")
|
||||||
|
|
||||||
|
// Verify the generated private key is parsable
|
||||||
|
keyData, err := os.ReadFile(privateKeyPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = ssh.ParsePrivateKey(keyData)
|
||||||
|
assert.NoError(t, err, "Generated private key should be parsable by ssh.ParsePrivateKey")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 2: Existing key
|
||||||
|
t.Run("ExistingKey", func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Manually create a valid key pair for the test
|
||||||
|
rawPubKey, rawPrivKey, err := ed25519.GenerateKey(nil)
|
||||||
|
require.NoError(t, err, "Failed to generate raw ed25519 key pair for pre-existing key test")
|
||||||
|
|
||||||
|
// Marshal the private key into OpenSSH PEM format
|
||||||
|
pemBlock, err := ssh.MarshalPrivateKey(rawPrivKey, "")
|
||||||
|
require.NoError(t, err, "Failed to marshal private key to PEM block for pre-existing key test")
|
||||||
|
|
||||||
|
privateKeyBytes := pem.EncodeToMemory(pemBlock)
|
||||||
|
require.NotNil(t, privateKeyBytes, "PEM encoded private key bytes should not be nil")
|
||||||
|
|
||||||
|
privateKeyPath := filepath.Join(tempDir, "id_ed25519")
|
||||||
|
err = os.WriteFile(privateKeyPath, privateKeyBytes, 0600)
|
||||||
|
require.NoError(t, err, "Failed to write pre-existing private key")
|
||||||
|
|
||||||
|
// Determine the expected public key string
|
||||||
|
sshPubKey, err := ssh.NewPublicKey(rawPubKey)
|
||||||
|
require.NoError(t, err)
|
||||||
|
expectedPubKeyStr := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(sshPubKey)))
|
||||||
|
|
||||||
|
// Reset h.pubKey to ensure it's set by GetSSHKey from the file
|
||||||
|
hub.pubKey = ""
|
||||||
|
|
||||||
|
signer, err := hub.GetSSHKey(tempDir)
|
||||||
|
assert.NoError(t, err, "GetSSHKey should not error when reading an existing key")
|
||||||
|
assert.NotNil(t, signer, "GetSSHKey should return a non-nil signer for an existing key")
|
||||||
|
|
||||||
|
// Check if h.pubKey was set correctly to the public key from the file
|
||||||
|
assert.Equal(t, expectedPubKeyStr, hub.pubKey, "h.pubKey should match the existing public key")
|
||||||
|
|
||||||
|
// Verify the signer's public key matches the original public key
|
||||||
|
signerPubKey := signer.PublicKey()
|
||||||
|
marshaledSignerPubKey := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(signerPubKey)))
|
||||||
|
assert.Equal(t, expectedPubKeyStr, marshaledSignerPubKey, "Signer's public key should match the existing public key")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Case 3: Error cases
|
||||||
|
t.Run("ErrorCases", func(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
setupFunc func(dir string) error
|
||||||
|
errorCheck func(t *testing.T, err error)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "CorruptedKey",
|
||||||
|
setupFunc: func(dir string) error {
|
||||||
|
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte("this is not a valid SSH key"), 0600)
|
||||||
|
},
|
||||||
|
errorCheck: func(t *testing.T, err error) {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "ssh: no key found")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PermissionDenied",
|
||||||
|
setupFunc: func(dir string) error {
|
||||||
|
// Create the key file
|
||||||
|
keyPath := filepath.Join(dir, "id_ed25519")
|
||||||
|
if err := os.WriteFile(keyPath, []byte("dummy content"), 0600); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Make it read-only (can't be opened for writing in case a new key needs to be written)
|
||||||
|
return os.Chmod(keyPath, 0400)
|
||||||
|
},
|
||||||
|
errorCheck: func(t *testing.T, err error) {
|
||||||
|
// On read-only key, the parser will attempt to parse it and fail with "ssh: no key found"
|
||||||
|
assert.Error(t, err)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EmptyFile",
|
||||||
|
setupFunc: func(dir string) error {
|
||||||
|
// Create an empty file
|
||||||
|
return os.WriteFile(filepath.Join(dir, "id_ed25519"), []byte{}, 0600)
|
||||||
|
},
|
||||||
|
errorCheck: func(t *testing.T, err error) {
|
||||||
|
assert.Error(t, err)
|
||||||
|
// The error from attempting to parse an empty file
|
||||||
|
assert.Contains(t, err.Error(), "ssh: no key found")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the test case
|
||||||
|
err := tc.setupFunc(tempDir)
|
||||||
|
require.NoError(t, err, "Setup failed")
|
||||||
|
|
||||||
|
// Reset h.pubKey before each test case
|
||||||
|
hub.pubKey = ""
|
||||||
|
|
||||||
|
// Attempt to get SSH key
|
||||||
|
_, err = hub.GetSSHKey(tempDir)
|
||||||
|
|
||||||
|
// Verify the error
|
||||||
|
tc.errorCheck(t, err)
|
||||||
|
|
||||||
|
// Check that pubKey was not set in error cases
|
||||||
|
assert.Empty(t, hub.pubKey, "h.pubKey should not be set if there was an error")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package systems
|
package systems
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"beszel/internal/common"
|
||||||
"beszel/internal/entities/system"
|
"beszel/internal/entities/system"
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -45,7 +46,7 @@ type System struct {
|
|||||||
|
|
||||||
type hubLike interface {
|
type hubLike interface {
|
||||||
core.App
|
core.App
|
||||||
GetSSHKey() ([]byte, error)
|
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||||
}
|
}
|
||||||
@@ -62,13 +63,10 @@ func NewSystemManager(hub hubLike) *SystemManager {
|
|||||||
func (sm *SystemManager) Initialize() error {
|
func (sm *SystemManager) Initialize() error {
|
||||||
sm.bindEventHooks()
|
sm.bindEventHooks()
|
||||||
// ssh setup
|
// ssh setup
|
||||||
key, err := sm.hub.GetSSHKey()
|
err := sm.createSSHClientConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := sm.createSSHClientConfig(key); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
// start updating existing systems
|
// start updating existing systems
|
||||||
var systems []*System
|
var systems []*System
|
||||||
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
|
err = sm.hub.DB().NewQuery("SELECT id, host, port, status FROM systems WHERE status != 'paused'").All(&systems)
|
||||||
@@ -124,7 +122,8 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
newStatus := e.Record.GetString("status")
|
newStatus := e.Record.GetString("status")
|
||||||
switch newStatus {
|
switch newStatus {
|
||||||
case paused:
|
case paused:
|
||||||
sm.RemoveSystem(e.Record.Id)
|
_ = sm.RemoveSystem(e.Record.Id)
|
||||||
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
case pending:
|
case pending:
|
||||||
if err := sm.AddRecord(e.Record); err != nil {
|
if err := sm.AddRecord(e.Record); err != nil {
|
||||||
@@ -362,15 +361,21 @@ func (sys *System) fetchDataFromAgent() (*system.CombinedData, error) {
|
|||||||
return nil, fmt.Errorf("failed to fetch data")
|
return nil, fmt.Errorf("failed to fetch data")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *SystemManager) createSSHClientConfig(key []byte) error {
|
// createSSHClientConfig initializes the ssh config for the system manager
|
||||||
signer, err := ssh.ParsePrivateKey(key)
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
|
privateKey, err := sm.hub.GetSSHKey(sm.hub.DataDir())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
sm.sshConfig = &ssh.ClientConfig{
|
sm.sshConfig = &ssh.ClientConfig{
|
||||||
User: "u",
|
User: "u",
|
||||||
Auth: []ssh.AuthMethod{
|
Auth: []ssh.AuthMethod{
|
||||||
ssh.PublicKeys(signer),
|
ssh.PublicKeys(privateKey),
|
||||||
|
},
|
||||||
|
Config: ssh.Config{
|
||||||
|
Ciphers: common.DefaultCiphers,
|
||||||
|
KeyExchanges: common.DefaultKeyExchanges,
|
||||||
|
MACs: common.DefaultMACs,
|
||||||
},
|
},
|
||||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
Timeout: sessionTimeout,
|
Timeout: sessionTimeout,
|
||||||
@@ -433,3 +438,20 @@ func (sys *System) resetSSHClient() {
|
|||||||
}
|
}
|
||||||
sys.client = nil
|
sys.client = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// deactivateAlerts finds all triggered alerts for a system and sets them to false
|
||||||
|
func deactivateAlerts(app core.App, systemID string) error {
|
||||||
|
// we can't use an UPDATE query because it doesn't work with realtime updates
|
||||||
|
// _, err := e.App.DB().NewQuery(fmt.Sprintf("UPDATE alerts SET triggered = false WHERE system = '%s'", e.Record.Id)).Execute()
|
||||||
|
alerts, err := app.FindRecordsByFilter("alerts", fmt.Sprintf("system = '%s' && triggered = 1", systemID), "", -1, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, alert := range alerts {
|
||||||
|
alert.Set("triggered", false)
|
||||||
|
if err := app.SaveNoValidate(alert); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ function copyLinuxCommand(port = "45876", publicKey: string, brew = false) {
|
|||||||
|
|
||||||
function copyWindowsCommand(port = "45876", publicKey: string) {
|
function copyWindowsCommand(port = "45876", publicKey: string) {
|
||||||
copyToClipboard(
|
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}`
|
`& iwr -useb https://get.beszel.dev -OutFile "$env:TEMP\\install-agent.ps1"; & Powershell -ExecutionPolicy Bypass -File "$env:TEMP\\install-agent.ps1" -Key "${publicKey}" -Port ${port}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,24 +301,20 @@ const CopyButton = memo((props: CopyButtonProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
{props.dropdownItems.map((item, index) => (
|
{props.dropdownItems.map((item, index) => {
|
||||||
<DropdownMenuItem key={index} asChild={!!item.url}>
|
const className = "cursor-pointer flex items-center gap-1.5"
|
||||||
{item.url ? (
|
return item.url ? (
|
||||||
<a
|
<DropdownMenuItem key={index} asChild>
|
||||||
href={item.url}
|
<a href={item.url} className={className} target="_blank" rel="noopener noreferrer">
|
||||||
className="cursor-pointer flex items-center gap-1.5"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
|
||||||
{item.text} {item.icons?.map((icon) => icon)}
|
{item.text} {item.icons?.map((icon) => icon)}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
</DropdownMenuItem>
|
||||||
<div onClick={item.onClick} className="cursor-pointer flex items-center gap-1.5">
|
) : (
|
||||||
{item.text} {item.icons?.map((icon) => icon)}
|
<DropdownMenuItem key={index} onClick={item.onClick} className={className}>
|
||||||
</div>
|
{item.text} {item.icons?.map((icon) => icon)}
|
||||||
)}
|
</DropdownMenuItem>
|
||||||
</DropdownMenuItem>
|
)
|
||||||
))}
|
})}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro"
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { memo, useMemo, useState } from "react"
|
import { memo, useMemo, useState } from "react"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { $alerts } from "@/lib/stores"
|
import { $alerts } from "@/lib/stores"
|
||||||
@@ -15,10 +15,11 @@ import { BellIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
|||||||
import { alertInfo, cn } from "@/lib/utils"
|
import { alertInfo, cn } from "@/lib/utils"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { AlertRecord, SystemRecord } from "@/types"
|
import { AlertRecord, SystemRecord } from "@/types"
|
||||||
import { Link } from "../router"
|
import { $router, 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 { getPagePath } from "@nanostores/router"
|
||||||
|
|
||||||
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
export default memo(function AlertsButton({ system }: { system: SystemRecord }) {
|
||||||
const alerts = useStore($alerts)
|
const alerts = useStore($alerts)
|
||||||
@@ -81,7 +82,7 @@ function AlertDialogContent({ system }: { system: SystemRecord }) {
|
|||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
<Trans>
|
<Trans>
|
||||||
See{" "}
|
See{" "}
|
||||||
<Link href="/settings/notifications" className="link">
|
<Link href={getPagePath($router, "settings", { name: "notifications" })} className="link">
|
||||||
notification settings
|
notification settings
|
||||||
</Link>{" "}
|
</Link>{" "}
|
||||||
to configure how you receive alerts.
|
to configure how you receive alerts.
|
||||||
|
|||||||
@@ -35,10 +35,12 @@ import { Input } from "../ui/input"
|
|||||||
import { ChartAverage, ChartMax, Rows, TuxIcon, WindowsIcon, AppleIcon, FreeBsdIcon } 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "../ui/tabs"
|
||||||
import { timeTicks } from "d3-time"
|
import { timeTicks } from "d3-time"
|
||||||
import { useLingui } from "@lingui/react/macro"
|
import { useLingui } from "@lingui/react/macro"
|
||||||
import { $router, navigate } from "../router"
|
import { $router, navigate } from "../router"
|
||||||
import { getPagePath } from "@nanostores/router"
|
import { getPagePath } from "@nanostores/router"
|
||||||
|
import DisksTab from "../tabs/disks-tab"
|
||||||
|
|
||||||
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"))
|
||||||
@@ -463,6 +465,14 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* tabs for different views */}
|
||||||
|
<Tabs defaultValue="systems" className="w-full">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="systems">Systems</TabsTrigger>
|
||||||
|
<TabsTrigger value="disks">Disks</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="systems" className="mt-4">
|
||||||
{/* main charts */}
|
{/* main charts */}
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
@@ -660,6 +670,12 @@ export default function SystemDetail({ name }: { name: string }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="disks" className="mt-4">
|
||||||
|
<DisksTab smartData={systemStats.at(-1)?.stats.sm} />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* add space for tooltip if more than 12 containers */}
|
{/* add space for tooltip if more than 12 containers */}
|
||||||
|
|||||||
631
beszel/site/src/components/tabs/disks-tab.tsx
Normal file
631
beszel/site/src/components/tabs/disks-tab.tsx
Normal file
@@ -0,0 +1,631 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
SortingState,
|
||||||
|
useReactTable,
|
||||||
|
VisibilityState,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import { Activity, Box, Binary, Container, ChevronDown, Clock, HardDrive, Thermometer, Tags, MoreHorizontal } from "lucide-react"
|
||||||
|
|
||||||
|
import { Button } from "../ui/button"
|
||||||
|
import { Card, CardHeader, CardTitle, CardDescription } from "../ui/card"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../ui/dialog"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../ui/dropdown-menu"
|
||||||
|
import { Input } from "../ui/input"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../ui/table"
|
||||||
|
import { Badge } from "../ui/badge"
|
||||||
|
import { SmartData, SmartAttribute } from "@/types"
|
||||||
|
|
||||||
|
|
||||||
|
// Column definition for S.M.A.R.T. attributes table
|
||||||
|
export const smartColumns: ColumnDef<SmartAttribute>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "id",
|
||||||
|
header: "ID",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const id = row.getValue("id") as number | undefined
|
||||||
|
return <div className="font-medium">{id || ""}</div>
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "n",
|
||||||
|
header: "Name",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.getValue("n")}</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "rs",
|
||||||
|
header: "Value",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
// if raw string is not empty, use it, otherwise use raw value
|
||||||
|
const rawString = row.getValue("rs") as string | undefined
|
||||||
|
const rawValue = row.original.rv
|
||||||
|
const displayValue = rawString || rawValue?.toString() || "-"
|
||||||
|
return <div className="font-mono text-sm">{displayValue}</div>
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "v",
|
||||||
|
header: "Normalized",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.getValue("v")}</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "w",
|
||||||
|
header: "Worst",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const worst = row.getValue("w") as number | undefined
|
||||||
|
return <div>{worst || ""}</div>
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "t",
|
||||||
|
header: "Threshold",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const threshold = row.getValue("t") as number | undefined
|
||||||
|
return <div>{threshold || ""}</div>
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "f",
|
||||||
|
header: "Flags",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const flags = row.getValue("f") as string | undefined
|
||||||
|
return <div className="font-mono text-sm">{flags || ""}</div>
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "wf",
|
||||||
|
header: "Failing",
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const whenFailed = row.getValue("wf") as string | undefined
|
||||||
|
return <div className="font-mono text-sm">{whenFailed || ""}</div>
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export type DiskInfo = {
|
||||||
|
device: string
|
||||||
|
model: string
|
||||||
|
serialNumber: string
|
||||||
|
firmwareVersion: string
|
||||||
|
capacity: string
|
||||||
|
status: string
|
||||||
|
temperature: number
|
||||||
|
deviceType: string
|
||||||
|
powerOnHours?: number
|
||||||
|
powerCycles?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to format capacity display
|
||||||
|
function formatCapacity(bytes: number): string {
|
||||||
|
const units = [
|
||||||
|
{ name: 'PB', size: 1024 ** 5 },
|
||||||
|
{ name: 'TB', size: 1024 ** 4 },
|
||||||
|
{ name: 'GB', size: 1024 ** 3 },
|
||||||
|
{ name: 'MB', size: 1024 ** 2 },
|
||||||
|
{ name: 'KB', size: 1024 ** 1 },
|
||||||
|
{ name: 'B', size: 1 }
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const unit of units) {
|
||||||
|
if (bytes >= unit.size) {
|
||||||
|
const value = bytes / unit.size
|
||||||
|
// For bytes, don't show decimals; for other units show one decimal place
|
||||||
|
const decimals = unit.name === 'B' ? 0 : 1
|
||||||
|
return `${value.toFixed(decimals)} ${unit.name}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '0 B'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to convert SmartData to DiskInfo
|
||||||
|
function convertSmartDataToDiskInfo(smartDataRecord: Record<string, SmartData>): DiskInfo[] {
|
||||||
|
return Object.entries(smartDataRecord).map(([key, smartData]) => ({
|
||||||
|
device: smartData.dn || key,
|
||||||
|
model: smartData.mn || "Unknown",
|
||||||
|
serialNumber: smartData.sn || "Unknown",
|
||||||
|
firmwareVersion: smartData.fv || "Unknown",
|
||||||
|
capacity: smartData.c ? formatCapacity(smartData.c) : "Unknown",
|
||||||
|
status: smartData.s || "Unknown",
|
||||||
|
temperature: smartData.t || 0,
|
||||||
|
deviceType: smartData.dt || "Unknown",
|
||||||
|
// These fields need to be extracted from SmartAttribute if available
|
||||||
|
powerOnHours: smartData.a?.find(attr => attr.n.toLowerCase().includes("poweronhours") || attr.n.toLowerCase().includes("power_on_hours"))?.rv,
|
||||||
|
powerCycles: smartData.a?.find(attr => attr.n.toLowerCase().includes("power") && attr.n.toLowerCase().includes("cycle"))?.rv,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// S.M.A.R.T. details dialog component
|
||||||
|
function SmartDialog({ disk, smartData }: { disk: DiskInfo; smartData?: SmartData }) {
|
||||||
|
const [open, setOpen] = React.useState(false)
|
||||||
|
|
||||||
|
const smartAttributes = smartData?.a || []
|
||||||
|
|
||||||
|
// Find all attributes where when failed is not empty
|
||||||
|
const failedAttributes = smartAttributes.filter(attr => attr.wf && attr.wf.trim() !== '')
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: smartAttributes,
|
||||||
|
columns: smartColumns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
enableSorting: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
||||||
|
View S.M.A.R.T.
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-4xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>S.M.A.R.T. Details - {disk.device}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
S.M.A.R.T. attributes for {disk.model} ({disk.serialNumber})
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{smartData?.s && (
|
||||||
|
<div className={`p-4 rounded-md ${
|
||||||
|
smartData.s === "PASSED"
|
||||||
|
? "bg-green-100 dark:bg-green-900 border border-green-200 dark:border-green-800"
|
||||||
|
: "bg-red-100 dark:bg-red-900 border border-red-200 dark:border-red-800"
|
||||||
|
}`}>
|
||||||
|
<h4 className={`font-semibold ${
|
||||||
|
smartData.s === "PASSED"
|
||||||
|
? "text-green-800 dark:text-green-200"
|
||||||
|
: "text-red-800 dark:text-red-200"
|
||||||
|
}`}>
|
||||||
|
S.M.A.R.T. Self-Test: {smartData.s}
|
||||||
|
</h4>
|
||||||
|
{failedAttributes.length > 0 && (
|
||||||
|
<p className="mt-2 text-red-800 dark:text-red-200">
|
||||||
|
Failed Attributes: {failedAttributes.map(attr => attr.n).join(", ")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
{smartAttributes.length > 0 ? (
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => {
|
||||||
|
// Check if the attribute is failed
|
||||||
|
const isFailedAttribute = row.original.wf && row.original.wf.trim() !== '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
className={isFailedAttribute ? "text-red-600 dark:text-red-400" : ""}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
No S.M.A.R.T. attributes available for this device.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const columns: ColumnDef<DiskInfo>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: "device",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<HardDrive className="mr-2 h-4 w-4" />
|
||||||
|
Device
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.getValue("device")}</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "model",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Box className="mr-2 h-4 w-4" />
|
||||||
|
Model
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="max-w-[200px] truncate" title={row.getValue("model")}>
|
||||||
|
{row.getValue("model")}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "capacity",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Container className="mr-2 h-4 w-4" />
|
||||||
|
Capacity
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium">{row.getValue("capacity")}</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "temperature",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Thermometer className="mr-2 h-4 w-4" />
|
||||||
|
Temp.
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const temp = row.getValue("temperature") as number
|
||||||
|
const getTemperatureColor = (temp: number) => {
|
||||||
|
if (temp >= 60) return "destructive"
|
||||||
|
if (temp >= 45) return "secondary"
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant={getTemperatureColor(temp)}>
|
||||||
|
{temp}°C
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "status",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Activity className="mr-2 h-4 w-4" />
|
||||||
|
Status
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const status = row.getValue("status") as string
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={status === "PASSED" ? "default" : "destructive"}
|
||||||
|
className={status === "PASSED" ? "bg-green-500 hover:bg-green-600 text-white" : ""}
|
||||||
|
>
|
||||||
|
{status}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "deviceType",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Tags className="mr-2 h-4 w-4" />
|
||||||
|
Type
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge variant="outline" className="uppercase">
|
||||||
|
{row.getValue("deviceType")}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "powerOnHours",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Clock className="mr-2 h-4 w-4" />
|
||||||
|
Power On Time
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const hours = row.getValue("powerOnHours") as number | undefined
|
||||||
|
if (!hours && hours !== 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
N/A
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return (
|
||||||
|
<div className="text-sm">
|
||||||
|
<div>{hours.toLocaleString()} hours</div>
|
||||||
|
<div className="text-muted-foreground text-xs">{days} days</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "serialNumber",
|
||||||
|
header: () => (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Binary className="mr-2 h-4 w-4" />
|
||||||
|
Serial Number
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-mono text-sm">{row.getValue("serialNumber")}</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "actions",
|
||||||
|
enableHiding: false,
|
||||||
|
cell: () => null, // This will be overwritten by columnsWithSmartData
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function DisksTab({ smartData }: { smartData?: Record<string, SmartData> }) {
|
||||||
|
const [sorting, setSorting] = React.useState<SortingState>([])
|
||||||
|
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
|
||||||
|
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({})
|
||||||
|
const [rowSelection, setRowSelection] = React.useState({})
|
||||||
|
|
||||||
|
// Convert SmartData to DiskInfo, if no data use empty array
|
||||||
|
const diskData = React.useMemo(() => {
|
||||||
|
return smartData ? convertSmartDataToDiskInfo(smartData) : []
|
||||||
|
}, [smartData])
|
||||||
|
|
||||||
|
// Create column definitions with SmartData
|
||||||
|
const columnsWithSmartData = React.useMemo(() => {
|
||||||
|
return columns.map(column => {
|
||||||
|
if (column.id === "actions") {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const disk = row.original as DiskInfo
|
||||||
|
// Find the corresponding SmartData
|
||||||
|
const diskSmartData = smartData ? Object.values(smartData).find(
|
||||||
|
sd => sd.dn === disk.device || sd.mn === disk.model
|
||||||
|
) : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||||
|
<SmartDialog disk={disk} smartData={diskSmartData} />
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigator.clipboard.writeText(disk.device)}
|
||||||
|
>
|
||||||
|
Copy device path
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => navigator.clipboard.writeText(disk.serialNumber)}
|
||||||
|
>
|
||||||
|
Copy serial number
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return column
|
||||||
|
})
|
||||||
|
}, [smartData])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: diskData,
|
||||||
|
columns: columnsWithSmartData,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnFilters,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Disk Information</CardTitle>
|
||||||
|
<CardDescription>Disk information and S.M.A.R.T. data</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center py-4">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter devices..."
|
||||||
|
value={(table.getColumn("device")?.getFilterValue() as string) ?? ""}
|
||||||
|
onChange={(event) =>
|
||||||
|
table.getColumn("device")?.setFilterValue(event.target.value)
|
||||||
|
}
|
||||||
|
className="max-w-sm"
|
||||||
|
/>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="ml-auto">
|
||||||
|
Columns <ChevronDown className="ml-2 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
column.toggleVisibility(!!value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border grid">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={columns.length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
{smartData ? "No disk data available." : "Loading disk data..."}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-end space-x-2 py-4">
|
||||||
|
<div className="text-muted-foreground flex-1 text-sm">
|
||||||
|
{table.getFilteredRowModel().rows.length} disk device(s)
|
||||||
|
</div>
|
||||||
|
<div className="space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -51,7 +51,7 @@ const TableHead = React.forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<
|
|||||||
<th
|
<th
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-12 px-4 text-start align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
"h-12 px-4 text-start align-middle whitespace-nowrap font-medium text-muted-foreground [&:has([role=checkbox])]:pe-0",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -62,7 +62,7 @@ TableHead.displayName = "TableHead"
|
|||||||
|
|
||||||
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
const TableCell = React.forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<td ref={ref} className={cn("p-4 align-middle [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
<td ref={ref} className={cn("p-4 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0", className)} {...props} />
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
TableCell.displayName = "TableCell"
|
TableCell.displayName = "TableCell"
|
||||||
|
|||||||
46
beszel/site/src/types.d.ts
vendored
46
beszel/site/src/types.d.ts
vendored
@@ -100,6 +100,8 @@ export interface SystemStats {
|
|||||||
efs?: Record<string, ExtraFsStats>
|
efs?: Record<string, ExtraFsStats>
|
||||||
/** GPU data */
|
/** GPU data */
|
||||||
g?: Record<string, GPUData>
|
g?: Record<string, GPUData>
|
||||||
|
/** SMART data */
|
||||||
|
sm?: Record<string, SmartData>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GPUData {
|
export interface GPUData {
|
||||||
@@ -208,3 +210,47 @@ interface AlertInfo {
|
|||||||
/** Single value description (when there's only one value, like status) */
|
/** Single value description (when there's only one value, like status) */
|
||||||
singleDesc?: () => string
|
singleDesc?: () => string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SmartData {
|
||||||
|
/** model family */
|
||||||
|
mf?: string
|
||||||
|
/** model name */
|
||||||
|
mn?: string
|
||||||
|
/** serial number */
|
||||||
|
sn?: string
|
||||||
|
/** firmware version */
|
||||||
|
fv?: string
|
||||||
|
/** capacity */
|
||||||
|
c?: number
|
||||||
|
/** smart status */
|
||||||
|
s?: string
|
||||||
|
/** disk name (like /dev/sda) */
|
||||||
|
dn?: string
|
||||||
|
/** disk type */
|
||||||
|
dt?: string
|
||||||
|
/** temperature */
|
||||||
|
t?: number
|
||||||
|
/** attributes */
|
||||||
|
a?: SmartAttribute[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmartAttribute {
|
||||||
|
/** id */
|
||||||
|
id?: number
|
||||||
|
/** name */
|
||||||
|
n: string
|
||||||
|
/** value */
|
||||||
|
v: number
|
||||||
|
/** worst */
|
||||||
|
w?: number
|
||||||
|
/** threshold */
|
||||||
|
t?: number
|
||||||
|
/** raw value */
|
||||||
|
rv?: number
|
||||||
|
/** raw string */
|
||||||
|
rs?: string
|
||||||
|
/** flags */
|
||||||
|
f?: string
|
||||||
|
/** when failed */
|
||||||
|
wf?: string
|
||||||
|
}
|
||||||
|
|||||||
18
readme.md
18
readme.md
@@ -14,13 +14,13 @@ It has a friendly web interface, simple configuration, and is ready to use out o
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
- **Lightweight**: Smaller and less resource-intensive than leading solutions.
|
||||||
- **Simple**: Easy setup, no need for public internet exposure.
|
- **Simple**: Easy setup with little manual configuration required.
|
||||||
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
- **Docker stats**: Tracks CPU, memory, and network usage history for each container.
|
||||||
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and status.
|
- **Alerts**: Configurable alerts for CPU, memory, disk, bandwidth, temperature, and status.
|
||||||
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
|
- **Multi-user**: Users manage their own systems. Admins can share systems across users.
|
||||||
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
- **OAuth / OIDC**: Supports many OAuth2 providers. Password auth can be disabled.
|
||||||
- **Automatic backups**: Save and restore data from disk or S3-compatible storage.
|
- **Automatic backups**: Save to and restore from disk or S3-compatible storage.
|
||||||
- **REST API**: Use or update your data in your own scripts and applications.
|
<!-- - **REST API**: Use or update your data in your own scripts and applications. -->
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -49,6 +49,18 @@ The [quick start guide](https://beszel.dev/guide/getting-started) and other docu
|
|||||||
- **Temperature** - Host system sensors.
|
- **Temperature** - Host system sensors.
|
||||||
- **GPU usage / temperature / power draw** - Nvidia and AMD only. Must use binary agent.
|
- **GPU usage / temperature / power draw** - Nvidia and AMD only. Must use binary agent.
|
||||||
|
|
||||||
|
## Help and discussion
|
||||||
|
|
||||||
|
Please search existing issues and discussions before opening a new one. I try my best to respond, but may not always have time to do so.
|
||||||
|
|
||||||
|
#### Bug reports and feature requests
|
||||||
|
|
||||||
|
Bug reports and detailed feature requests should be posted on [GitHub issues](https://github.com/henrygd/beszel/issues).
|
||||||
|
|
||||||
|
#### Support and general discussion
|
||||||
|
|
||||||
|
Support requests and general discussion can be posted on [GitHub discussions](https://github.com/henrygd/beszel/discussions) or the community-run [Matrix room](https://matrix.to/#/#beszel:matrix.org): `#beszel:matrix.org`.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
|
Beszel is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details.
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ if ! getent passwd "$SERVICE_USER" >/dev/null; then
|
|||||||
--gecos "System user for $SERVICE"
|
--gecos "System user for $SERVICE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Enable docker
|
||||||
|
if ! getent group docker | grep -q "$SERVICE_USER"; then
|
||||||
|
echo "Adding $SERVICE_USER to docker group"
|
||||||
|
usermod -aG docker "$SERVICE_USER"
|
||||||
|
fi
|
||||||
|
|
||||||
# Create config file if it doesn't already exist
|
# Create config file if it doesn't already exist
|
||||||
if [ ! -f "$CONFIG_FILE" ]; then
|
if [ ! -f "$CONFIG_FILE" ]; then
|
||||||
touch "$CONFIG_FILE"
|
touch "$CONFIG_FILE"
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ param (
|
|||||||
[switch]$Elevated,
|
[switch]$Elevated,
|
||||||
[Parameter(Mandatory=$true)]
|
[Parameter(Mandatory=$true)]
|
||||||
[string]$Key,
|
[string]$Key,
|
||||||
[int]$Port = 45876
|
[int]$Port = 45876,
|
||||||
|
[string]$AgentPath = "",
|
||||||
|
[string]$NSSMPath = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if key is provided or empty
|
# Check if key is provided or empty
|
||||||
@@ -15,60 +17,245 @@ if ([string]::IsNullOrWhiteSpace($Key)) {
|
|||||||
# Stop on first error
|
# Stop on first error
|
||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
#region Utility Functions
|
||||||
|
|
||||||
# Function to check if running as admin
|
# Function to check if running as admin
|
||||||
function Test-Admin {
|
function Test-Admin {
|
||||||
return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
return ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Non-admin tasks - install Scoop and Scoop apps - Only run if we're not in elevated mode
|
# Function to check if a command exists
|
||||||
if (-not $Elevated) {
|
function Test-CommandExists {
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$Command
|
||||||
|
)
|
||||||
|
return (Get-Command $Command -ErrorAction SilentlyContinue)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to find beszel-agent in common installation locations
|
||||||
|
function Find-BeszelAgent {
|
||||||
|
# First check if it's in PATH
|
||||||
|
$agentCmd = Get-Command "beszel-agent" -ErrorAction SilentlyContinue
|
||||||
|
if ($agentCmd) {
|
||||||
|
return $agentCmd.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Common installation paths to check
|
||||||
|
$commonPaths = @(
|
||||||
|
"$env:USERPROFILE\scoop\apps\beszel-agent\current\beszel-agent.exe",
|
||||||
|
"$env:ProgramData\scoop\apps\beszel-agent\current\beszel-agent.exe",
|
||||||
|
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
|
||||||
|
"$env:ProgramFiles\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
|
||||||
|
"${env:ProgramFiles(x86)}\WinGet\Packages\henrygd.beszel-agent*\beszel-agent.exe",
|
||||||
|
"$env:ProgramFiles\beszel-agent\beszel-agent.exe",
|
||||||
|
"$env:ProgramFiles(x86)\beszel-agent\beszel-agent.exe",
|
||||||
|
"$env:SystemDrive\Users\*\scoop\apps\beszel-agent\current\beszel-agent.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($path in $commonPaths) {
|
||||||
|
# Handle wildcard paths
|
||||||
|
if ($path.Contains("*")) {
|
||||||
|
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
|
||||||
|
if ($foundPaths) {
|
||||||
|
return $foundPaths[0].FullName
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (Test-Path $path) {
|
||||||
|
return $path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to find NSSM in common installation locations
|
||||||
|
function Find-NSSM {
|
||||||
|
# First check if it's in PATH
|
||||||
|
$nssmCmd = Get-Command "nssm" -ErrorAction SilentlyContinue
|
||||||
|
if ($nssmCmd) {
|
||||||
|
return $nssmCmd.Source
|
||||||
|
}
|
||||||
|
|
||||||
|
# Common installation paths to check
|
||||||
|
$commonPaths = @(
|
||||||
|
"$env:USERPROFILE\scoop\apps\nssm\current\nssm.exe",
|
||||||
|
"$env:ProgramData\scoop\apps\nssm\current\nssm.exe",
|
||||||
|
"$env:LOCALAPPDATA\Microsoft\WinGet\Packages\NSSM.NSSM*\nssm.exe",
|
||||||
|
"$env:ProgramFiles\WinGet\Packages\NSSM.NSSM*\nssm.exe",
|
||||||
|
"${env:ProgramFiles(x86)}\WinGet\Packages\NSSM.NSSM*\nssm.exe",
|
||||||
|
"$env:SystemDrive\Users\*\scoop\apps\nssm\current\nssm.exe"
|
||||||
|
)
|
||||||
|
|
||||||
|
foreach ($path in $commonPaths) {
|
||||||
|
# Handle wildcard paths
|
||||||
|
if ($path.Contains("*")) {
|
||||||
|
$foundPaths = Get-ChildItem -Path $path -ErrorAction SilentlyContinue
|
||||||
|
if ($foundPaths) {
|
||||||
|
return $foundPaths[0].FullName
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (Test-Path $path) {
|
||||||
|
return $path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $null
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Installation Methods
|
||||||
|
|
||||||
|
# Function to install Scoop
|
||||||
|
function Install-Scoop {
|
||||||
|
Write-Host "Installing Scoop..."
|
||||||
|
|
||||||
|
# Check if running as admin - Scoop should not be installed as admin
|
||||||
|
if (Test-Admin) {
|
||||||
|
throw "Scoop cannot be installed with administrator privileges. Please run this script as a regular user first to install Scoop and beszel-agent, then run as admin to configure the service."
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
# Check if Scoop is already installed
|
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
||||||
if (Get-Command scoop -ErrorAction SilentlyContinue) {
|
|
||||||
|
if (-not (Test-CommandExists "scoop")) {
|
||||||
|
throw "Failed to install Scoop - command not available after installation"
|
||||||
|
}
|
||||||
|
Write-Host "Scoop installed successfully."
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw "Failed to install Scoop: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to install Git via Scoop
|
||||||
|
function Install-Git {
|
||||||
|
if (Test-CommandExists "git") {
|
||||||
|
Write-Host "Git is already installed."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installing Git..."
|
||||||
|
scoop install git
|
||||||
|
|
||||||
|
if (-not (Test-CommandExists "git")) {
|
||||||
|
throw "Failed to install Git"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to install NSSM
|
||||||
|
function Install-NSSM {
|
||||||
|
param (
|
||||||
|
[string]$Method = "Scoop" # Default to Scoop method
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Test-CommandExists "nssm") {
|
||||||
|
Write-Host "NSSM is already installed."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Installing NSSM..."
|
||||||
|
if ($Method -eq "Scoop") {
|
||||||
|
scoop install nssm
|
||||||
|
}
|
||||||
|
elseif ($Method -eq "WinGet") {
|
||||||
|
winget install -e --id NSSM.NSSM --accept-source-agreements --accept-package-agreements
|
||||||
|
|
||||||
|
# Refresh PATH environment variable to make NSSM available in current session
|
||||||
|
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
throw "Unsupported installation method: $Method"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not (Test-CommandExists "nssm")) {
|
||||||
|
throw "Failed to install NSSM"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to install beszel-agent with Scoop
|
||||||
|
function Install-BeszelAgentWithScoop {
|
||||||
|
Write-Host "Adding beszel bucket..."
|
||||||
|
scoop bucket add beszel https://github.com/henrygd/beszel-scoops | Out-Null
|
||||||
|
|
||||||
|
Write-Host "Installing / updating beszel-agent..."
|
||||||
|
scoop install beszel-agent
|
||||||
|
|
||||||
|
if (-not (Test-CommandExists "beszel-agent")) {
|
||||||
|
throw "Failed to install beszel-agent"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $(Join-Path -Path $(scoop prefix beszel-agent) -ChildPath "beszel-agent.exe")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to install beszel-agent with WinGet
|
||||||
|
function Install-BeszelAgentWithWinGet {
|
||||||
|
Write-Host "Installing / updating beszel-agent..."
|
||||||
|
|
||||||
|
# Temporarily change ErrorActionPreference to allow WinGet to complete and show output
|
||||||
|
$originalErrorActionPreference = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = "Continue"
|
||||||
|
|
||||||
|
# Use call operator (&) and capture exit code properly
|
||||||
|
& winget install --exact --id henrygd.beszel-agent --accept-source-agreements --accept-package-agreements | Out-Null
|
||||||
|
$wingetExitCode = $LASTEXITCODE
|
||||||
|
|
||||||
|
# Restore original ErrorActionPreference
|
||||||
|
$ErrorActionPreference = $originalErrorActionPreference
|
||||||
|
|
||||||
|
# WinGet exit codes:
|
||||||
|
# 0 = Success
|
||||||
|
# -1978335212 (0x8A150014) = No applicable upgrade found (package is up to date)
|
||||||
|
# -1978335189 (0x8A15002B) = Another "no upgrade needed" variant
|
||||||
|
# Other codes indicate actual errors
|
||||||
|
if ($wingetExitCode -eq -1978335212 -or $wingetExitCode -eq -1978335189) {
|
||||||
|
Write-Host "Package is already up to date." -ForegroundColor Green
|
||||||
|
} elseif ($wingetExitCode -ne 0) {
|
||||||
|
Write-Host "WinGet exit code: $wingetExitCode" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# Refresh PATH environment variable to make beszel-agent available in current session
|
||||||
|
$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User")
|
||||||
|
|
||||||
|
# Find the path to the beszel-agent executable
|
||||||
|
$agentPath = (Get-Command beszel-agent -ErrorAction SilentlyContinue).Source
|
||||||
|
|
||||||
|
if (-not $agentPath) {
|
||||||
|
throw "Could not find beszel-agent executable path after installation"
|
||||||
|
}
|
||||||
|
|
||||||
|
return $agentPath
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to install using Scoop
|
||||||
|
function Install-WithScoop {
|
||||||
|
param (
|
||||||
|
[string]$Key,
|
||||||
|
[int]$Port
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Ensure Scoop is installed
|
||||||
|
if (-not (Test-CommandExists "scoop")) {
|
||||||
|
Install-Scoop | Out-Null
|
||||||
|
}
|
||||||
|
else {
|
||||||
Write-Host "Scoop is already installed."
|
Write-Host "Scoop is already installed."
|
||||||
} else {
|
|
||||||
Write-Host "Installing Scoop..."
|
|
||||||
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
|
||||||
|
|
||||||
if (-not (Get-Command scoop -ErrorAction SilentlyContinue)) {
|
|
||||||
throw "Failed to install Scoop"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if git is already installed
|
|
||||||
if (Get-Command git -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Host "Git is already installed."
|
|
||||||
} else {
|
|
||||||
Write-Host "Installing Git..."
|
|
||||||
scoop install git
|
|
||||||
|
|
||||||
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
|
|
||||||
throw "Failed to install Git"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
# Check if nssm is already installed
|
|
||||||
if (Get-Command nssm -ErrorAction SilentlyContinue) {
|
|
||||||
Write-Host "NSSM is already installed."
|
|
||||||
} else {
|
|
||||||
Write-Host "Installing NSSM..."
|
|
||||||
scoop install nssm
|
|
||||||
|
|
||||||
if (-not (Get-Command nssm -ErrorAction SilentlyContinue)) {
|
|
||||||
throw "Failed to install NSSM"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Add bucket and install agent
|
# Install Git (required for Scoop buckets)
|
||||||
Write-Host "Adding beszel bucket..."
|
Install-Git | Out-Null
|
||||||
scoop bucket add beszel https://github.com/henrygd/beszel-scoops
|
|
||||||
|
|
||||||
Write-Host "Installing beszel-agent..."
|
# Install NSSM
|
||||||
scoop install beszel-agent
|
Install-NSSM -Method "Scoop" | Out-Null
|
||||||
|
|
||||||
if (-not (Get-Command beszel-agent -ErrorAction SilentlyContinue)) {
|
# Install beszel-agent
|
||||||
throw "Failed to install beszel-agent"
|
$agentPath = Install-BeszelAgentWithScoop
|
||||||
}
|
|
||||||
|
return $agentPath
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
@@ -77,49 +264,80 @@ if (-not $Elevated) {
|
|||||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# Check if we need admin privileges for the NSSM part
|
# Function to install using WinGet
|
||||||
if (-not (Test-Admin)) {
|
function Install-WithWinGet {
|
||||||
Write-Host "Admin privileges required for NSSM. Relaunching as admin..." -ForegroundColor Yellow
|
param (
|
||||||
Write-Host "Check service status with 'nssm status beszel-agent'"
|
[string]$Key,
|
||||||
Write-Host "Edit service configuration with 'nssm edit beszel-agent'"
|
[int]$Port
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
# Install NSSM
|
||||||
|
Install-NSSM -Method "WinGet" | Out-Null
|
||||||
|
|
||||||
# Relaunch the script with the -Elevated switch and pass parameters
|
# Install beszel-agent
|
||||||
Start-Process powershell.exe -Verb RunAs -ArgumentList "-File `"$PSCommandPath`" -Elevated -Key `"$Key`" -Port $Port"
|
$agentPath = Install-BeszelAgentWithWinGet
|
||||||
exit
|
|
||||||
|
return $agentPath
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
|
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
|
||||||
|
Write-Host "Press any key to exit..." -ForegroundColor Red
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Admin tasks - service installation and firewall rules
|
#endregion
|
||||||
try {
|
|
||||||
$agentPath = Join-Path -Path $(scoop prefix beszel-agent) -ChildPath "beszel-agent.exe"
|
#region Service Configuration
|
||||||
if (-not $agentPath) {
|
|
||||||
throw "Could not find beszel-agent executable. Make sure it was properly installed."
|
# Function to install and configure the NSSM service
|
||||||
}
|
function Install-NSSMService {
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$AgentPath,
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[string]$Key,
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[int]$Port,
|
||||||
|
[string]$NSSMPath = ""
|
||||||
|
)
|
||||||
|
|
||||||
# Install and configure the service
|
|
||||||
Write-Host "Installing beszel-agent service..."
|
Write-Host "Installing beszel-agent service..."
|
||||||
|
|
||||||
|
# Determine the NSSM executable to use
|
||||||
|
$nssmCommand = "nssm"
|
||||||
|
if ($NSSMPath -and (Test-Path $NSSMPath)) {
|
||||||
|
$nssmCommand = $NSSMPath
|
||||||
|
Write-Host "Using NSSM from: $NSSMPath"
|
||||||
|
} elseif (-not (Test-CommandExists "nssm")) {
|
||||||
|
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
|
||||||
|
}
|
||||||
|
|
||||||
# Check if service already exists
|
# Check if service already exists
|
||||||
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
|
$existingService = Get-Service -Name "beszel-agent" -ErrorAction SilentlyContinue
|
||||||
if ($existingService) {
|
if ($existingService) {
|
||||||
Write-Host "Service already exists. Stopping and removing existing service..."
|
Write-Host "Service already exists. Stopping and removing existing service..."
|
||||||
try {
|
try {
|
||||||
nssm stop beszel-agent
|
& $nssmCommand stop beszel-agent
|
||||||
nssm remove beszel-agent confirm
|
& $nssmCommand remove beszel-agent confirm
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host "Warning: Failed to remove existing service: $($_.Exception.Message)" -ForegroundColor Yellow
|
Write-Host "Warning: Failed to remove existing service: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nssm install beszel-agent $agentPath
|
& $nssmCommand install beszel-agent $AgentPath
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
throw "Failed to install beszel-agent service"
|
throw "Failed to install beszel-agent service"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Configuring service environment variables..."
|
Write-Host "Configuring service environment variables..."
|
||||||
nssm set beszel-agent AppEnvironmentExtra "+KEY=$Key"
|
& $nssmCommand set beszel-agent AppEnvironmentExtra "+KEY=$Key"
|
||||||
nssm set beszel-agent AppEnvironmentExtra "+PORT=$Port"
|
& $nssmCommand set beszel-agent AppEnvironmentExtra "+PORT=$Port"
|
||||||
|
|
||||||
# Configure log files
|
# Configure log files
|
||||||
$logDir = "$env:ProgramData\beszel-agent\logs"
|
$logDir = "$env:ProgramData\beszel-agent\logs"
|
||||||
@@ -127,8 +345,16 @@ try {
|
|||||||
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
New-Item -ItemType Directory -Path $logDir -Force | Out-Null
|
||||||
}
|
}
|
||||||
$logFile = "$logDir\beszel-agent.log"
|
$logFile = "$logDir\beszel-agent.log"
|
||||||
nssm set beszel-agent AppStdout $logFile
|
& $nssmCommand set beszel-agent AppStdout $logFile
|
||||||
nssm set beszel-agent AppStderr $logFile
|
& $nssmCommand set beszel-agent AppStderr $logFile
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to configure firewall rules
|
||||||
|
function Configure-Firewall {
|
||||||
|
param (
|
||||||
|
[Parameter(Mandatory=$true)]
|
||||||
|
[int]$Port
|
||||||
|
)
|
||||||
|
|
||||||
# Create a firewall rule if it doesn't exist
|
# Create a firewall rule if it doesn't exist
|
||||||
$ruleName = "Allow beszel-agent"
|
$ruleName = "Allow beszel-agent"
|
||||||
@@ -154,31 +380,202 @@ try {
|
|||||||
Write-Host "Warning: Failed to create firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
|
Write-Host "Warning: Failed to create firewall rule: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
Write-Host "You may need to manually create a firewall rule for port $Port." -ForegroundColor Yellow
|
Write-Host "You may need to manually create a firewall rule for port $Port." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Function to start and monitor the service
|
||||||
|
function Start-BeszelAgentService {
|
||||||
|
param (
|
||||||
|
[string]$NSSMPath = ""
|
||||||
|
)
|
||||||
|
|
||||||
Write-Host "Starting beszel-agent service..."
|
Write-Host "Starting beszel-agent service..."
|
||||||
nssm start beszel-agent
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
# Determine the NSSM executable to use
|
||||||
throw "Failed to start beszel-agent service"
|
$nssmCommand = "nssm"
|
||||||
|
if ($NSSMPath -and (Test-Path $NSSMPath)) {
|
||||||
|
$nssmCommand = $NSSMPath
|
||||||
|
} elseif (-not (Test-CommandExists "nssm")) {
|
||||||
|
throw "NSSM is not available in PATH and no valid NSSMPath was provided"
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Checking beszel-agent service status..."
|
& $nssmCommand start beszel-agent
|
||||||
Start-Sleep -Seconds 5 # Allow time to start before checking status
|
$startResult = $LASTEXITCODE
|
||||||
$serviceStatus = nssm status beszel-agent
|
|
||||||
|
|
||||||
if ($serviceStatus -eq "SERVICE_RUNNING") {
|
# Only enter the status check loop if the NSSM start command failed
|
||||||
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green
|
if ($startResult -ne 0) {
|
||||||
|
Write-Host "NSSM start command returned error code: $startResult" -ForegroundColor Yellow
|
||||||
|
Write-Host "This could be due to 'SERVICE_START_PENDING' state. Checking service status..."
|
||||||
|
|
||||||
|
# Allow up to 10 seconds for the service to start, checking every second
|
||||||
|
$maxWaitTime = 10 # seconds
|
||||||
|
$elapsedTime = 0
|
||||||
|
$serviceStarted = $false
|
||||||
|
|
||||||
|
while (-not $serviceStarted -and $elapsedTime -lt $maxWaitTime) {
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
$elapsedTime += 1
|
||||||
|
|
||||||
|
$serviceStatus = & $nssmCommand status beszel-agent
|
||||||
|
|
||||||
|
if ($serviceStatus -eq "SERVICE_RUNNING") {
|
||||||
|
$serviceStarted = $true
|
||||||
|
Write-Host "Success! The beszel-agent service is now running." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
elseif ($serviceStatus -like "*PENDING*") {
|
||||||
|
Write-Host "Service is still starting (status: $serviceStatus)... waiting" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Warning: The service status is '$serviceStatus' instead of 'SERVICE_RUNNING'." -ForegroundColor Yellow
|
||||||
|
Write-Host "You may need to troubleshoot the service installation." -ForegroundColor Yellow
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $serviceStarted) {
|
||||||
|
Write-Host "Service did not reach running state." -ForegroundColor Yellow
|
||||||
|
Write-Host "You can check status manually with 'nssm status beszel-agent'" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Host "Warning: The service status is '$serviceStatus' instead of 'SERVICE_RUNNING'." -ForegroundColor Yellow
|
# NSSM start command was successful
|
||||||
Write-Host "You may need to troubleshoot the service installation." -ForegroundColor Yellow
|
Write-Host "Success! The beszel-agent service is running properly." -ForegroundColor Green
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Main Script Execution
|
||||||
|
|
||||||
|
# Check if we're running as admin
|
||||||
|
$isAdmin = Test-Admin
|
||||||
|
|
||||||
|
try {
|
||||||
|
# First: Install the agent (doesn't require admin)
|
||||||
|
if (-not $AgentPath) {
|
||||||
|
# Check for problematic case: running as admin and need Scoop
|
||||||
|
if ($isAdmin -and -not (Test-CommandExists "scoop") -and -not (Test-CommandExists "winget")) {
|
||||||
|
Write-Host "ERROR: You're running as administrator but neither Scoop nor WinGet is available." -ForegroundColor Red
|
||||||
|
Write-Host "Scoop should be installed without admin privileges." -ForegroundColor Red
|
||||||
|
Write-Host ""
|
||||||
|
Write-Host "Please either:" -ForegroundColor Yellow
|
||||||
|
Write-Host "1. Run this script again without administrator privileges" -ForegroundColor Yellow
|
||||||
|
Write-Host "2. Install WinGet and run this script again" -ForegroundColor Yellow
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Test-CommandExists "scoop") {
|
||||||
|
Write-Host "Using Scoop for installation..."
|
||||||
|
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
||||||
|
}
|
||||||
|
elseif (Test-CommandExists "winget") {
|
||||||
|
Write-Host "Using WinGet for installation..."
|
||||||
|
$AgentPath = Install-WithWinGet -Key $Key -Port $Port
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
Write-Host "Neither Scoop nor WinGet is installed. Installing Scoop..."
|
||||||
|
$AgentPath = Install-WithScoop -Key $Key -Port $Port
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (-not $AgentPath) {
|
||||||
|
throw "Could not find beszel-agent executable. Make sure it was properly installed."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Find NSSM path if not already provided
|
||||||
|
if (-not $NSSMPath) {
|
||||||
|
$NSSMPath = Find-NSSM
|
||||||
|
|
||||||
|
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
|
||||||
|
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
|
||||||
|
}
|
||||||
|
|
||||||
|
# If we still don't have NSSM, try to install it if we have package managers
|
||||||
|
if (-not $NSSMPath) {
|
||||||
|
if (Test-CommandExists "winget") {
|
||||||
|
Write-Host "NSSM not found. Attempting to install via WinGet..."
|
||||||
|
try {
|
||||||
|
Install-NSSM -Method "WinGet"
|
||||||
|
$NSSMPath = Find-NSSM
|
||||||
|
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
|
||||||
|
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to install NSSM via WinGet: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
} elseif (Test-CommandExists "scoop") {
|
||||||
|
Write-Host "NSSM not found. Attempting to install via Scoop..."
|
||||||
|
try {
|
||||||
|
Install-NSSM -Method "Scoop"
|
||||||
|
$NSSMPath = Find-NSSM
|
||||||
|
if (-not $NSSMPath -and (Test-CommandExists "nssm")) {
|
||||||
|
$NSSMPath = (Get-Command "nssm" -ErrorAction SilentlyContinue).Source
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Write-Host "Failed to install NSSM via Scoop: $($_.Exception.Message)" -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Final check - if we still don't have NSSM and we're admin, we have a problem
|
||||||
|
if (-not $NSSMPath -and ($isAdmin -or $Elevated)) {
|
||||||
|
throw "NSSM is required for service installation but was not found and could not be installed. Please install NSSM manually or run as a regular user to install it."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Second: If we need admin rights for service installation and we don't have them, relaunch
|
||||||
|
if (-not $isAdmin -and -not $Elevated) {
|
||||||
|
Write-Host "Admin privileges required for service installation. Relaunching as admin..." -ForegroundColor Yellow
|
||||||
|
Write-Host "Check service status with 'nssm status beszel-agent'"
|
||||||
|
Write-Host "Edit service configuration with 'nssm edit beszel-agent'"
|
||||||
|
|
||||||
|
# Prepare arguments for the elevated script
|
||||||
|
$argumentList = @(
|
||||||
|
"-ExecutionPolicy", "Bypass",
|
||||||
|
"-File", "`"$PSCommandPath`"",
|
||||||
|
"-Elevated",
|
||||||
|
"-Key", "`"$Key`"",
|
||||||
|
"-Port", $Port,
|
||||||
|
"-AgentPath", "`"$AgentPath`""
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add NSSMPath if we found it
|
||||||
|
if ($NSSMPath) {
|
||||||
|
$argumentList += "-NSSMPath"
|
||||||
|
$argumentList += "`"$NSSMPath`""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Relaunch the script with the -Elevated switch and pass parameters
|
||||||
|
Start-Process powershell.exe -Verb RunAs -ArgumentList $argumentList
|
||||||
|
exit
|
||||||
|
}
|
||||||
|
|
||||||
|
# Third: If we have admin rights, install service and configure firewall
|
||||||
|
if ($isAdmin -or $Elevated) {
|
||||||
|
# Install the service
|
||||||
|
Install-NSSMService -AgentPath $AgentPath -Key $Key -Port $Port -NSSMPath $NSSMPath
|
||||||
|
|
||||||
|
# Configure firewall
|
||||||
|
Configure-Firewall -Port $Port
|
||||||
|
|
||||||
|
# Start the service
|
||||||
|
Start-BeszelAgentService -NSSMPath $NSSMPath
|
||||||
|
|
||||||
|
# Pause to see results if this is an elevated window
|
||||||
|
if ($Elevated) {
|
||||||
|
Write-Host "Press any key to exit..." -ForegroundColor Cyan
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
Write-Host "ERROR: $($_.Exception.Message)" -ForegroundColor Red
|
||||||
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
|
Write-Host "Installation failed. Please check the error message above." -ForegroundColor Red
|
||||||
|
|
||||||
|
# Pause if this is likely a new window
|
||||||
|
if ($Elevated -or (-not $isAdmin)) {
|
||||||
|
Write-Host "Press any key to exit..." -ForegroundColor Red
|
||||||
|
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# Pause to see results before exit if this is an elevated window
|
#endregion
|
||||||
if ($Elevated) {
|
|
||||||
Write-Host "Press any key to exit..." -ForegroundColor Cyan
|
|
||||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,7 +8,53 @@ is_openwrt() {
|
|||||||
cat /etc/os-release | grep -q "OpenWrt"
|
cat /etc/os-release | grep -q "OpenWrt"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Function to ensure the proxy URL ends with a /
|
# If SELinux is enabled, set the context of the binary
|
||||||
|
set_selinux_context() {
|
||||||
|
# Check if SELinux is enabled and in enforcing or permissive mode
|
||||||
|
if command -v getenforce >/dev/null 2>&1; then
|
||||||
|
SELINUX_MODE=$(getenforce)
|
||||||
|
if [ "$SELINUX_MODE" != "Disabled" ]; then
|
||||||
|
echo "SELinux is enabled (${SELINUX_MODE} mode). Setting appropriate context..."
|
||||||
|
|
||||||
|
# First try to set persistent context if semanage is available
|
||||||
|
if command -v semanage >/dev/null 2>&1; then
|
||||||
|
echo "Attempting to set persistent SELinux context..."
|
||||||
|
if semanage fcontext -a -t bin_t "/opt/beszel-agent/beszel-agent" >/dev/null 2>&1; then
|
||||||
|
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1
|
||||||
|
else
|
||||||
|
echo "Warning: Failed to set persistent context, falling back to temporary context."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fall back to chcon if semanage failed or isn't available
|
||||||
|
if command -v chcon >/dev/null 2>&1; then
|
||||||
|
# Set context for both the directory and binary
|
||||||
|
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: Failed to set SELinux context for binary."
|
||||||
|
chcon -R -t bin_t /opt/beszel-agent || echo "Warning: Failed to set SELinux context for directory."
|
||||||
|
else
|
||||||
|
if [ "$SELINUX_MODE" = "Enforcing" ]; then
|
||||||
|
echo "Warning: SELinux is in enforcing mode but chcon command not found. The service may fail to start."
|
||||||
|
echo "Consider installing the policycoreutils package or temporarily setting SELinux to permissive mode."
|
||||||
|
else
|
||||||
|
echo "Warning: SELinux is in permissive mode but chcon command not found."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up SELinux contexts if they were set
|
||||||
|
cleanup_selinux_context() {
|
||||||
|
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||||
|
echo "Cleaning up SELinux contexts..."
|
||||||
|
# Remove persistent context if semanage is available
|
||||||
|
if command -v semanage >/dev/null 2>&1; then
|
||||||
|
semanage fcontext -d "/opt/beszel-agent/beszel-agent" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure the proxy URL ends with a /
|
||||||
ensure_trailing_slash() {
|
ensure_trailing_slash() {
|
||||||
if [ -n "$1" ]; then
|
if [ -n "$1" ]; then
|
||||||
case "$1" in
|
case "$1" in
|
||||||
@@ -20,7 +66,7 @@ ensure_trailing_slash() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# Define default values
|
# Default values
|
||||||
PORT=45876
|
PORT=45876
|
||||||
UNINSTALL=false
|
UNINSTALL=false
|
||||||
GITHUB_URL="https://github.com"
|
GITHUB_URL="https://github.com"
|
||||||
@@ -141,6 +187,9 @@ done
|
|||||||
|
|
||||||
# Uninstall process
|
# Uninstall process
|
||||||
if [ "$UNINSTALL" = true ]; then
|
if [ "$UNINSTALL" = true ]; then
|
||||||
|
# Clean up SELinux contexts before removing files
|
||||||
|
cleanup_selinux_context
|
||||||
|
|
||||||
if is_alpine; then
|
if is_alpine; then
|
||||||
echo "Stopping and disabling the agent service..."
|
echo "Stopping and disabling the agent service..."
|
||||||
rc-service beszel-agent stop
|
rc-service beszel-agent stop
|
||||||
@@ -215,7 +264,7 @@ if [ -n "$GITHUB_PROXY_URL" ]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Function to check if a package is installed
|
# Check if a package is installed
|
||||||
package_installed() {
|
package_installed() {
|
||||||
command -v "$1" >/dev/null 2>&1
|
command -v "$1" >/dev/null 2>&1
|
||||||
}
|
}
|
||||||
@@ -268,14 +317,14 @@ fi
|
|||||||
if is_alpine; then
|
if is_alpine; then
|
||||||
if ! id -u beszel >/dev/null 2>&1; then
|
if ! id -u beszel >/dev/null 2>&1; then
|
||||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||||
adduser -D -H -s /sbin/nologin beszel
|
adduser -S -D -H -s /sbin/nologin beszel
|
||||||
fi
|
fi
|
||||||
# Add the user to the docker group to allow access to the Docker socket
|
# Add the user to the docker group to allow access to the Docker socket
|
||||||
addgroup beszel docker
|
addgroup beszel docker
|
||||||
else
|
else
|
||||||
if ! id -u beszel >/dev/null 2>&1; then
|
if ! id -u beszel >/dev/null 2>&1; then
|
||||||
echo "Creating a dedicated user for the Beszel Agent service..."
|
echo "Creating a dedicated user for the Beszel Agent service..."
|
||||||
useradd -M -s /bin/false beszel
|
useradd --system --home-dir /nonexistent --shell /bin/false beszel
|
||||||
fi
|
fi
|
||||||
# Add the user to the docker group to allow access to the Docker socket
|
# Add the user to the docker group to allow access to the Docker socket
|
||||||
usermod -aG docker beszel
|
usermod -aG docker beszel
|
||||||
@@ -334,9 +383,23 @@ mv beszel-agent /opt/beszel-agent/beszel-agent
|
|||||||
chown beszel:beszel /opt/beszel-agent/beszel-agent
|
chown beszel:beszel /opt/beszel-agent/beszel-agent
|
||||||
chmod 755 /opt/beszel-agent/beszel-agent
|
chmod 755 /opt/beszel-agent/beszel-agent
|
||||||
|
|
||||||
|
# Set SELinux context if needed
|
||||||
|
set_selinux_context
|
||||||
|
|
||||||
# Cleanup
|
# Cleanup
|
||||||
rm -rf "$TEMP_DIR"
|
rm -rf "$TEMP_DIR"
|
||||||
|
|
||||||
|
# Check for NVIDIA GPUs and grant device permissions for systemd service
|
||||||
|
detect_nvidia_devices() {
|
||||||
|
local devices=""
|
||||||
|
for i in /dev/nvidia*; do
|
||||||
|
if [ -e "$i" ]; then
|
||||||
|
devices="${devices}DeviceAllow=$i rw\n"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo "$devices"
|
||||||
|
}
|
||||||
|
|
||||||
# Modify service installation part, add Alpine check before systemd service creation
|
# Modify service installation part, add Alpine check before systemd service creation
|
||||||
if is_alpine; then
|
if is_alpine; then
|
||||||
echo "Creating OpenRC service for Alpine Linux..."
|
echo "Creating OpenRC service for Alpine Linux..."
|
||||||
@@ -508,6 +571,10 @@ EOF
|
|||||||
else
|
else
|
||||||
# Original systemd service installation code
|
# Original systemd service installation code
|
||||||
echo "Creating the systemd service for the agent..."
|
echo "Creating the systemd service for the agent..."
|
||||||
|
|
||||||
|
# Detect NVIDIA devices and grant device permissions
|
||||||
|
NVIDIA_DEVICES=$(detect_nvidia_devices)
|
||||||
|
|
||||||
cat >/etc/systemd/system/beszel-agent.service <<EOF
|
cat >/etc/systemd/system/beszel-agent.service <<EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Beszel Agent Service
|
Description=Beszel Agent Service
|
||||||
@@ -528,7 +595,6 @@ StateDirectory=beszel-agent
|
|||||||
KeyringMode=private
|
KeyringMode=private
|
||||||
LockPersonality=yes
|
LockPersonality=yes
|
||||||
NoNewPrivileges=yes
|
NoNewPrivileges=yes
|
||||||
PrivateTmp=yes
|
|
||||||
ProtectClock=yes
|
ProtectClock=yes
|
||||||
ProtectHome=read-only
|
ProtectHome=read-only
|
||||||
ProtectHostname=yes
|
ProtectHostname=yes
|
||||||
@@ -538,6 +604,8 @@ RemoveIPC=yes
|
|||||||
RestrictSUIDSGID=true
|
RestrictSUIDSGID=true
|
||||||
SystemCallArchitectures=native
|
SystemCallArchitectures=native
|
||||||
|
|
||||||
|
$(if [ -n "$NVIDIA_DEVICES" ]; then printf "%b" "# NVIDIA device permissions\n${NVIDIA_DEVICES}"; fi)
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
@@ -548,6 +616,37 @@ EOF
|
|||||||
systemctl enable beszel-agent.service
|
systemctl enable beszel-agent.service
|
||||||
systemctl start beszel-agent.service
|
systemctl start beszel-agent.service
|
||||||
|
|
||||||
|
# Create the update script
|
||||||
|
echo "Creating the update script..."
|
||||||
|
cat >/opt/beszel-agent/run-update.sh <<'EOF'
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if /opt/beszel-agent/beszel-agent update | grep -q "Successfully updated"; then
|
||||||
|
echo "Update found, checking SELinux context."
|
||||||
|
if command -v getenforce >/dev/null 2>&1 && [ "$(getenforce)" != "Disabled" ]; then
|
||||||
|
echo "SELinux enabled, applying context..."
|
||||||
|
if command -v chcon >/dev/null 2>&1; then
|
||||||
|
chcon -t bin_t /opt/beszel-agent/beszel-agent || echo "Warning: chcon command failed to apply context."
|
||||||
|
fi
|
||||||
|
if command -v restorecon >/dev/null 2>&1; then
|
||||||
|
restorecon -v /opt/beszel-agent/beszel-agent >/dev/null 2>&1 || echo "Warning: restorecon command failed to apply context."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
echo "Restarting beszel-agent service..."
|
||||||
|
systemctl restart beszel-agent
|
||||||
|
echo "Update process finished."
|
||||||
|
else
|
||||||
|
echo "No updates found or applied."
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chown root:root /opt/beszel-agent/run-update.sh
|
||||||
|
chmod +x /opt/beszel-agent/run-update.sh
|
||||||
|
|
||||||
# Prompt for auto-update setup
|
# Prompt for auto-update setup
|
||||||
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
if [ "$AUTO_UPDATE_FLAG" = "true" ]; then
|
||||||
AUTO_UPDATE="y"
|
AUTO_UPDATE="y"
|
||||||
@@ -571,7 +670,7 @@ Wants=beszel-agent.service
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/bin/sh -c '/opt/beszel-agent/beszel-agent update | grep -q "Successfully updated" && (echo "Update found, restarting beszel-agent" && systemctl restart beszel-agent) || echo "No updates found"'
|
ExecStart=/opt/beszel-agent/run-update.sh
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# Create systemd timer for the daily update
|
# Create systemd timer for the daily update
|
||||||
|
|||||||
Reference in New Issue
Block a user