mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-21 12:11:49 +02:00
Compare commits
70 Commits
v0.18.6
...
temp-probe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cef5ab10a5 | ||
|
|
3a881e1d5e | ||
|
|
209bb4ebb4 | ||
|
|
e71ffd4d2a | ||
|
|
ea19ef6334 | ||
|
|
40da2b4358 | ||
|
|
d0d5912d85 | ||
|
|
4162186ae0 | ||
|
|
a71617e058 | ||
|
|
578ba985e9 | ||
|
|
e5507fa106 | ||
|
|
a024c3cfd0 | ||
|
|
07466804e7 | ||
|
|
981c788d6f | ||
|
|
f5576759de | ||
|
|
be0b708064 | ||
|
|
485830452e | ||
|
|
2fd00cd0b5 | ||
|
|
853a294157 | ||
|
|
aa9ab49654 | ||
|
|
9a5959b57e | ||
|
|
50f8548479 | ||
|
|
bc0581ea61 | ||
|
|
ab3a3de46c | ||
|
|
1556e53926 | ||
|
|
e3ade3aeb8 | ||
|
|
b013f06956 | ||
|
|
fab5e8a656 | ||
|
|
3a0896e57e | ||
|
|
7fdc403470 | ||
|
|
e833d44c43 | ||
|
|
77dd4bdaf5 | ||
|
|
ecba63c4bb | ||
|
|
f9feaf5343 | ||
|
|
ddf5e925c8 | ||
|
|
865e6db90f | ||
|
|
a42d899e64 | ||
|
|
3eaf12a7d5 | ||
|
|
3793b27958 | ||
|
|
5b02158228 | ||
|
|
0ae8c42ae0 | ||
|
|
ea80f3c5a2 | ||
|
|
c3dffff5e4 | ||
|
|
06fdd0e7a8 | ||
|
|
6e3fd90834 | ||
|
|
5ab82183fa | ||
|
|
a68e02ca84 | ||
|
|
0f2e16c63c | ||
|
|
c4009f2b43 | ||
|
|
ef0c1420d1 | ||
|
|
eb9a8e1ef9 | ||
|
|
6b5e6ffa9a | ||
|
|
d656036d3b | ||
|
|
80b73c7faf | ||
|
|
afe9eb7a70 | ||
|
|
7f565a3086 | ||
|
|
77862d4cb1 | ||
|
|
e158a9001b | ||
|
|
f670e868e4 | ||
|
|
0fff699bf6 | ||
|
|
ba10da1b9f | ||
|
|
7f4f14b505 | ||
|
|
2fda4ff264 | ||
|
|
20b0b40ec8 | ||
|
|
d548a012b4 | ||
|
|
ce5d1217dd | ||
|
|
cef09d7cb1 | ||
|
|
f6440acb43 | ||
|
|
5463a38f0f | ||
|
|
80135fdad3 |
@@ -48,6 +48,7 @@ type Agent struct {
|
||||
keys []gossh.PublicKey // SSH public keys
|
||||
smartManager *SmartManager // Manages SMART data
|
||||
systemdManager *systemdManager // Manages systemd services
|
||||
probeManager *ProbeManager // Manages network probes
|
||||
}
|
||||
|
||||
// NewAgent creates a new agent with the given data directory for persisting data.
|
||||
@@ -121,6 +122,9 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
||||
// initialize handler registry
|
||||
agent.handlerRegistry = NewHandlerRegistry()
|
||||
|
||||
// initialize probe manager
|
||||
agent.probeManager = newProbeManager()
|
||||
|
||||
// initialize disk info
|
||||
agent.initializeDiskInfo()
|
||||
|
||||
@@ -178,6 +182,11 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
||||
}
|
||||
}
|
||||
|
||||
if a.probeManager != nil {
|
||||
data.Probes = a.probeManager.GetResults()
|
||||
slog.Debug("Probes", "data", data.Probes)
|
||||
}
|
||||
|
||||
// skip updating systemd services if cache time is not the default 60sec interval
|
||||
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||
|
||||
@@ -1,84 +1,11 @@
|
||||
//go:build !freebsd
|
||||
|
||||
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
||||
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
|
||||
"github.com/distatus/battery"
|
||||
const (
|
||||
stateUnknown uint8 = iota
|
||||
stateEmpty
|
||||
stateFull
|
||||
stateCharging
|
||||
stateDischarging
|
||||
stateIdle
|
||||
)
|
||||
|
||||
var (
|
||||
systemHasBattery = false
|
||||
haveCheckedBattery = false
|
||||
)
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
func HasReadableBattery() bool {
|
||||
if haveCheckedBattery {
|
||||
return systemHasBattery
|
||||
}
|
||||
haveCheckedBattery = true
|
||||
batteries, err := battery.GetAll()
|
||||
for _, bat := range batteries {
|
||||
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
|
||||
systemHasBattery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !systemHasBattery {
|
||||
slog.Debug("No battery found", "err", err)
|
||||
}
|
||||
return systemHasBattery
|
||||
}
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state
|
||||
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
batteries, err := battery.GetAll()
|
||||
// we'll handle errors later by skipping batteries with errors, rather
|
||||
// than skipping everything because of the presence of some errors.
|
||||
if len(batteries) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
}
|
||||
|
||||
totalCapacity := float64(0)
|
||||
totalCharge := float64(0)
|
||||
errs, partialErrs := err.(battery.Errors)
|
||||
|
||||
batteryState = math.MaxUint8
|
||||
|
||||
for i, bat := range batteries {
|
||||
if partialErrs && errs[i] != nil {
|
||||
// if there were some errors, like missing data, skip it
|
||||
continue
|
||||
}
|
||||
if bat == nil || bat.Full == 0 {
|
||||
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
|
||||
// we can't guarantee that, so don't skip based on charge.
|
||||
continue
|
||||
}
|
||||
totalCapacity += bat.Full
|
||||
totalCharge += min(bat.Current, bat.Full)
|
||||
if bat.State.Raw >= 0 {
|
||||
batteryState = uint8(bat.State.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||
// for macs there's sometimes a ghost battery with 0 capacity
|
||||
// https://github.com/distatus/battery/issues/34
|
||||
// Instead of skipping over those batteries, we'll check for total 0 capacity
|
||||
// and return an error. This also prevents a divide by zero.
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
|
||||
batteryPercent = uint8(totalCharge / totalCapacity * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
}
|
||||
|
||||
96
agent/battery/battery_darwin.go
Normal file
96
agent/battery/battery_darwin.go
Normal file
@@ -0,0 +1,96 @@
|
||||
//go:build darwin
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os/exec"
|
||||
"sync"
|
||||
|
||||
"howett.net/plist"
|
||||
)
|
||||
|
||||
type macBattery struct {
|
||||
CurrentCapacity int `plist:"CurrentCapacity"`
|
||||
MaxCapacity int `plist:"MaxCapacity"`
|
||||
FullyCharged bool `plist:"FullyCharged"`
|
||||
IsCharging bool `plist:"IsCharging"`
|
||||
ExternalConnected bool `plist:"ExternalConnected"`
|
||||
}
|
||||
|
||||
func readMacBatteries() ([]macBattery, error) {
|
||||
out, err := exec.Command("ioreg", "-n", "AppleSmartBattery", "-r", "-a").Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var batteries []macBattery
|
||||
if _, err := plist.Unmarshal(out, &batteries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return batteries, nil
|
||||
}
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
batteries, err := readMacBatteries()
|
||||
slog.Debug("Batteries", "batteries", batteries, "err", err)
|
||||
for _, bat := range batteries {
|
||||
if bat.MaxCapacity > 0 {
|
||||
systemHasBattery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
return systemHasBattery
|
||||
})
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state.
|
||||
// Uses CurrentCapacity/MaxCapacity to match the value macOS displays.
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
batteries, err := readMacBatteries()
|
||||
if len(batteries) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
}
|
||||
|
||||
totalCapacity := 0
|
||||
totalCharge := 0
|
||||
batteryState = math.MaxUint8
|
||||
|
||||
for _, bat := range batteries {
|
||||
if bat.MaxCapacity == 0 {
|
||||
// skip ghost batteries with 0 capacity
|
||||
// https://github.com/distatus/battery/issues/34
|
||||
continue
|
||||
}
|
||||
totalCapacity += bat.MaxCapacity
|
||||
totalCharge += min(bat.CurrentCapacity, bat.MaxCapacity)
|
||||
|
||||
switch {
|
||||
case !bat.ExternalConnected:
|
||||
batteryState = stateDischarging
|
||||
case bat.IsCharging:
|
||||
batteryState = stateCharging
|
||||
case bat.CurrentCapacity == 0:
|
||||
batteryState = stateEmpty
|
||||
case !bat.FullyCharged:
|
||||
batteryState = stateIdle
|
||||
default:
|
||||
batteryState = stateFull
|
||||
}
|
||||
}
|
||||
|
||||
if totalCapacity == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
|
||||
batteryPercent = uint8(float64(totalCharge) / float64(totalCapacity) * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
}
|
||||
120
agent/battery/battery_linux.go
Normal file
120
agent/battery/battery_linux.go
Normal file
@@ -0,0 +1,120 @@
|
||||
//go:build linux
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
)
|
||||
|
||||
// getBatteryPaths returns the paths of all batteries in /sys/class/power_supply
|
||||
var getBatteryPaths func() ([]string, error)
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery func() bool
|
||||
|
||||
func init() {
|
||||
resetBatteryState("/sys/class/power_supply")
|
||||
}
|
||||
|
||||
// resetBatteryState resets the sync.Once functions to a fresh state.
|
||||
// Tests call this after swapping sysfsPowerSupply so the new path is picked up.
|
||||
func resetBatteryState(sysfsPowerSupplyPath string) {
|
||||
getBatteryPaths = sync.OnceValues(func() ([]string, error) {
|
||||
entries, err := os.ReadDir(sysfsPowerSupplyPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var paths []string
|
||||
for _, e := range entries {
|
||||
path := filepath.Join(sysfsPowerSupplyPath, e.Name())
|
||||
if utils.ReadStringFile(filepath.Join(path, "type")) == "Battery" {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths, nil
|
||||
})
|
||||
HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
paths, err := getBatteryPaths()
|
||||
for _, path := range paths {
|
||||
if _, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity")); ok {
|
||||
systemHasBattery = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !systemHasBattery {
|
||||
slog.Debug("No battery found", "err", err)
|
||||
}
|
||||
return systemHasBattery
|
||||
})
|
||||
}
|
||||
|
||||
func parseSysfsState(status string) uint8 {
|
||||
switch status {
|
||||
case "Empty":
|
||||
return stateEmpty
|
||||
case "Full":
|
||||
return stateFull
|
||||
case "Charging":
|
||||
return stateCharging
|
||||
case "Discharging":
|
||||
return stateDischarging
|
||||
case "Not charging":
|
||||
return stateIdle
|
||||
default:
|
||||
return stateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state.
|
||||
// Reads /sys/class/power_supply/*/capacity directly so the kernel-reported
|
||||
// value is used, which is always 0-100 and matches what the OS displays.
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
paths, err := getBatteryPaths()
|
||||
if err != nil {
|
||||
return batteryPercent, batteryState, err
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return batteryPercent, batteryState, errors.New("no batteries")
|
||||
}
|
||||
|
||||
batteryState = math.MaxUint8
|
||||
totalPercent := 0
|
||||
count := 0
|
||||
|
||||
for _, path := range paths {
|
||||
capStr, ok := utils.ReadStringFileOK(filepath.Join(path, "capacity"))
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
cap, parseErr := strconv.Atoi(capStr)
|
||||
if parseErr != nil {
|
||||
continue
|
||||
}
|
||||
totalPercent += cap
|
||||
count++
|
||||
|
||||
state := parseSysfsState(utils.ReadStringFile(filepath.Join(path, "status")))
|
||||
if state != stateUnknown {
|
||||
batteryState = state
|
||||
}
|
||||
}
|
||||
|
||||
if count == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
|
||||
batteryPercent = uint8(totalPercent / count)
|
||||
return batteryPercent, batteryState, nil
|
||||
}
|
||||
201
agent/battery/battery_linux_test.go
Normal file
201
agent/battery/battery_linux_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
//go:build testing && linux
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// setupFakeSysfs creates a temporary sysfs-like tree under t.TempDir(),
|
||||
// swaps sysfsPowerSupply, resets the sync.Once caches, and restores
|
||||
// everything on cleanup. Returns a helper to create battery directories.
|
||||
func setupFakeSysfs(t *testing.T) (tmpDir string, addBattery func(name, capacity, status string)) {
|
||||
t.Helper()
|
||||
|
||||
tmp := t.TempDir()
|
||||
resetBatteryState(tmp)
|
||||
|
||||
write := func(path, content string) {
|
||||
t.Helper()
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
addBattery = func(name, capacity, status string) {
|
||||
t.Helper()
|
||||
batDir := filepath.Join(tmp, name)
|
||||
write(filepath.Join(batDir, "type"), "Battery")
|
||||
write(filepath.Join(batDir, "capacity"), capacity)
|
||||
write(filepath.Join(batDir, "status"), status)
|
||||
}
|
||||
|
||||
return tmp, addBattery
|
||||
}
|
||||
|
||||
func TestParseSysfsState(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want uint8
|
||||
}{
|
||||
{"Empty", stateEmpty},
|
||||
{"Full", stateFull},
|
||||
{"Charging", stateCharging},
|
||||
{"Discharging", stateDischarging},
|
||||
{"Not charging", stateIdle},
|
||||
{"", stateUnknown},
|
||||
{"SomethingElse", stateUnknown},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
assert.Equal(t, tt.want, parseSysfsState(tt.input), "parseSysfsState(%q)", tt.input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_SingleBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "72", "Discharging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(72), pct)
|
||||
assert.Equal(t, stateDischarging, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_MultipleBatteries(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "80", "Charging")
|
||||
addBattery("BAT1", "40", "Charging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
// average of 80 and 40 = 60
|
||||
assert.EqualValues(t, 60, pct)
|
||||
assert.Equal(t, stateCharging, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_FullBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "100", "Full")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(100), pct)
|
||||
assert.Equal(t, stateFull, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_EmptyBattery(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "0", "Empty")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(0), pct)
|
||||
assert.Equal(t, stateEmpty, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_NotCharging(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "80", "Not charging")
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(80), pct)
|
||||
assert.Equal(t, stateIdle, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_NoBatteries(t *testing.T) {
|
||||
setupFakeSysfs(t) // empty directory, no batteries
|
||||
|
||||
_, _, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_NonBatterySupplyIgnored(t *testing.T) {
|
||||
tmp, addBattery := setupFakeSysfs(t)
|
||||
|
||||
// Add a real battery
|
||||
addBattery("BAT0", "55", "Charging")
|
||||
|
||||
// Add an AC adapter (type != Battery) - should be ignored
|
||||
acDir := filepath.Join(tmp, "AC0")
|
||||
if err := os.MkdirAll(acDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(acDir, "type"), []byte("Mains"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pct, state, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, uint8(55), pct)
|
||||
assert.Equal(t, stateCharging, state)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_InvalidCapacitySkipped(t *testing.T) {
|
||||
tmp, addBattery := setupFakeSysfs(t)
|
||||
|
||||
// One battery with valid capacity
|
||||
addBattery("BAT0", "90", "Discharging")
|
||||
|
||||
// Another with invalid capacity text
|
||||
badDir := filepath.Join(tmp, "BAT1")
|
||||
if err := os.MkdirAll(badDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(badDir, "type"), []byte("Battery"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(badDir, "capacity"), []byte("not-a-number"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(badDir, "status"), []byte("Discharging"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pct, _, err := GetBatteryStats()
|
||||
assert.NoError(t, err)
|
||||
// Only BAT0 counted
|
||||
assert.Equal(t, uint8(90), pct)
|
||||
}
|
||||
|
||||
func TestGetBatteryStats_UnknownStatusOnly(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "50", "SomethingWeird")
|
||||
|
||||
_, _, err := GetBatteryStats()
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHasReadableBattery_True(t *testing.T) {
|
||||
_, addBattery := setupFakeSysfs(t)
|
||||
addBattery("BAT0", "50", "Charging")
|
||||
|
||||
assert.True(t, HasReadableBattery())
|
||||
}
|
||||
|
||||
func TestHasReadableBattery_False(t *testing.T) {
|
||||
setupFakeSysfs(t) // no batteries
|
||||
|
||||
assert.False(t, HasReadableBattery())
|
||||
}
|
||||
|
||||
func TestHasReadableBattery_NoCapacityFile(t *testing.T) {
|
||||
tmp, _ := setupFakeSysfs(t)
|
||||
|
||||
// Battery dir with type file but no capacity file
|
||||
batDir := filepath.Join(tmp, "BAT0")
|
||||
err := os.MkdirAll(batDir, 0o755)
|
||||
assert.NoError(t, err)
|
||||
err = os.WriteFile(filepath.Join(batDir, "type"), []byte("Battery"), 0o644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
assert.False(t, HasReadableBattery())
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build freebsd
|
||||
//go:build !darwin && !linux && !windows
|
||||
|
||||
package battery
|
||||
|
||||
298
agent/battery/battery_windows.go
Normal file
298
agent/battery/battery_windows.go
Normal file
@@ -0,0 +1,298 @@
|
||||
//go:build windows
|
||||
|
||||
// Most of the Windows battery code is based on
|
||||
// distatus/battery by Karol 'Kenji Takahashi' Woźniak
|
||||
|
||||
package battery
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"math"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
type batteryQueryInformation struct {
|
||||
BatteryTag uint32
|
||||
InformationLevel int32
|
||||
AtRate int32
|
||||
}
|
||||
|
||||
type batteryInformation struct {
|
||||
Capabilities uint32
|
||||
Technology uint8
|
||||
Reserved [3]uint8
|
||||
Chemistry [4]uint8
|
||||
DesignedCapacity uint32
|
||||
FullChargedCapacity uint32
|
||||
DefaultAlert1 uint32
|
||||
DefaultAlert2 uint32
|
||||
CriticalBias uint32
|
||||
CycleCount uint32
|
||||
}
|
||||
|
||||
type batteryWaitStatus struct {
|
||||
BatteryTag uint32
|
||||
Timeout uint32
|
||||
PowerState uint32
|
||||
LowCapacity uint32
|
||||
HighCapacity uint32
|
||||
}
|
||||
|
||||
type batteryStatus struct {
|
||||
PowerState uint32
|
||||
Capacity uint32
|
||||
Voltage uint32
|
||||
Rate int32
|
||||
}
|
||||
|
||||
type winGUID struct {
|
||||
Data1 uint32
|
||||
Data2 uint16
|
||||
Data3 uint16
|
||||
Data4 [8]byte
|
||||
}
|
||||
|
||||
type spDeviceInterfaceData struct {
|
||||
cbSize uint32
|
||||
InterfaceClassGuid winGUID
|
||||
Flags uint32
|
||||
Reserved uint
|
||||
}
|
||||
|
||||
var guidDeviceBattery = winGUID{
|
||||
0x72631e54,
|
||||
0x78A4,
|
||||
0x11d0,
|
||||
[8]byte{0xbc, 0xf7, 0x00, 0xaa, 0x00, 0xb7, 0xb3, 0x2a},
|
||||
}
|
||||
|
||||
var (
|
||||
setupapi = &windows.LazyDLL{Name: "setupapi.dll", System: true}
|
||||
setupDiGetClassDevsW = setupapi.NewProc("SetupDiGetClassDevsW")
|
||||
setupDiEnumDeviceInterfaces = setupapi.NewProc("SetupDiEnumDeviceInterfaces")
|
||||
setupDiGetDeviceInterfaceDetailW = setupapi.NewProc("SetupDiGetDeviceInterfaceDetailW")
|
||||
setupDiDestroyDeviceInfoList = setupapi.NewProc("SetupDiDestroyDeviceInfoList")
|
||||
)
|
||||
|
||||
// winBatteryGet reads one battery by index. Returns (fullCapacity, currentCapacity, state, error).
|
||||
// Returns error == errNotFound when there are no more batteries.
|
||||
var errNotFound = errors.New("no more batteries")
|
||||
|
||||
func setupDiSetup(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) (uintptr, error) {
|
||||
_ = nargs
|
||||
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
||||
if windows.Handle(r1) == windows.InvalidHandle {
|
||||
if errno != 0 {
|
||||
return 0, error(errno)
|
||||
}
|
||||
return 0, syscall.EINVAL
|
||||
}
|
||||
return r1, nil
|
||||
}
|
||||
|
||||
func setupDiCall(proc *windows.LazyProc, nargs, a1, a2, a3, a4, a5, a6 uintptr) syscall.Errno {
|
||||
_ = nargs
|
||||
r1, _, errno := syscall.SyscallN(proc.Addr(), a1, a2, a3, a4, a5, a6)
|
||||
if r1 == 0 {
|
||||
if errno != 0 {
|
||||
return errno
|
||||
}
|
||||
return syscall.EINVAL
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readWinBatteryState(powerState uint32) uint8 {
|
||||
switch {
|
||||
case powerState&0x00000004 != 0:
|
||||
return stateCharging
|
||||
case powerState&0x00000008 != 0:
|
||||
return stateEmpty
|
||||
case powerState&0x00000002 != 0:
|
||||
return stateDischarging
|
||||
case powerState&0x00000001 != 0:
|
||||
return stateFull
|
||||
default:
|
||||
return stateUnknown
|
||||
}
|
||||
}
|
||||
|
||||
func winBatteryGet(idx int) (full, current uint32, state uint8, err error) {
|
||||
hdev, err := setupDiSetup(
|
||||
setupDiGetClassDevsW,
|
||||
4,
|
||||
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
||||
0, 0,
|
||||
2|16, // DIGCF_PRESENT|DIGCF_DEVICEINTERFACE
|
||||
0, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
defer syscall.SyscallN(setupDiDestroyDeviceInfoList.Addr(), hdev)
|
||||
|
||||
var did spDeviceInterfaceData
|
||||
did.cbSize = uint32(unsafe.Sizeof(did))
|
||||
errno := setupDiCall(
|
||||
setupDiEnumDeviceInterfaces,
|
||||
5,
|
||||
hdev, 0,
|
||||
uintptr(unsafe.Pointer(&guidDeviceBattery)),
|
||||
uintptr(idx),
|
||||
uintptr(unsafe.Pointer(&did)),
|
||||
0,
|
||||
)
|
||||
if errno == 259 { // ERROR_NO_MORE_ITEMS
|
||||
return 0, 0, stateUnknown, errNotFound
|
||||
}
|
||||
if errno != 0 {
|
||||
return 0, 0, stateUnknown, errno
|
||||
}
|
||||
|
||||
var cbRequired uint32
|
||||
errno = setupDiCall(
|
||||
setupDiGetDeviceInterfaceDetailW,
|
||||
6,
|
||||
hdev,
|
||||
uintptr(unsafe.Pointer(&did)),
|
||||
0, 0,
|
||||
uintptr(unsafe.Pointer(&cbRequired)),
|
||||
0,
|
||||
)
|
||||
if errno != 0 && errno != 122 { // ERROR_INSUFFICIENT_BUFFER
|
||||
return 0, 0, stateUnknown, errno
|
||||
}
|
||||
didd := make([]uint16, cbRequired/2)
|
||||
cbSize := (*uint32)(unsafe.Pointer(&didd[0]))
|
||||
if unsafe.Sizeof(uint(0)) == 8 {
|
||||
*cbSize = 8
|
||||
} else {
|
||||
*cbSize = 6
|
||||
}
|
||||
errno = setupDiCall(
|
||||
setupDiGetDeviceInterfaceDetailW,
|
||||
6,
|
||||
hdev,
|
||||
uintptr(unsafe.Pointer(&did)),
|
||||
uintptr(unsafe.Pointer(&didd[0])),
|
||||
uintptr(cbRequired),
|
||||
uintptr(unsafe.Pointer(&cbRequired)),
|
||||
0,
|
||||
)
|
||||
if errno != 0 {
|
||||
return 0, 0, stateUnknown, errno
|
||||
}
|
||||
devicePath := &didd[2:][0]
|
||||
|
||||
handle, err := windows.CreateFile(
|
||||
devicePath,
|
||||
windows.GENERIC_READ|windows.GENERIC_WRITE,
|
||||
windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE,
|
||||
nil,
|
||||
windows.OPEN_EXISTING,
|
||||
windows.FILE_ATTRIBUTE_NORMAL,
|
||||
0,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
defer windows.CloseHandle(handle)
|
||||
|
||||
var dwOut uint32
|
||||
var dwWait uint32
|
||||
var bqi batteryQueryInformation
|
||||
err = windows.DeviceIoControl(
|
||||
handle,
|
||||
2703424, // IOCTL_BATTERY_QUERY_TAG
|
||||
(*byte)(unsafe.Pointer(&dwWait)),
|
||||
uint32(unsafe.Sizeof(dwWait)),
|
||||
(*byte)(unsafe.Pointer(&bqi.BatteryTag)),
|
||||
uint32(unsafe.Sizeof(bqi.BatteryTag)),
|
||||
&dwOut, nil,
|
||||
)
|
||||
if err != nil || bqi.BatteryTag == 0 {
|
||||
return 0, 0, stateUnknown, errors.New("battery tag not returned")
|
||||
}
|
||||
|
||||
var bi batteryInformation
|
||||
if err = windows.DeviceIoControl(
|
||||
handle,
|
||||
2703428, // IOCTL_BATTERY_QUERY_INFORMATION
|
||||
(*byte)(unsafe.Pointer(&bqi)),
|
||||
uint32(unsafe.Sizeof(bqi)),
|
||||
(*byte)(unsafe.Pointer(&bi)),
|
||||
uint32(unsafe.Sizeof(bi)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
|
||||
bws := batteryWaitStatus{BatteryTag: bqi.BatteryTag}
|
||||
var bs batteryStatus
|
||||
if err = windows.DeviceIoControl(
|
||||
handle,
|
||||
2703436, // IOCTL_BATTERY_QUERY_STATUS
|
||||
(*byte)(unsafe.Pointer(&bws)),
|
||||
uint32(unsafe.Sizeof(bws)),
|
||||
(*byte)(unsafe.Pointer(&bs)),
|
||||
uint32(unsafe.Sizeof(bs)),
|
||||
&dwOut, nil,
|
||||
); err != nil {
|
||||
return 0, 0, stateUnknown, err
|
||||
}
|
||||
|
||||
if bs.Capacity == 0xffffffff { // BATTERY_UNKNOWN_CAPACITY
|
||||
return 0, 0, stateUnknown, errors.New("battery capacity unknown")
|
||||
}
|
||||
|
||||
return bi.FullChargedCapacity, bs.Capacity, readWinBatteryState(bs.PowerState), nil
|
||||
}
|
||||
|
||||
// HasReadableBattery checks if the system has a battery and returns true if it does.
|
||||
var HasReadableBattery = sync.OnceValue(func() bool {
|
||||
systemHasBattery := false
|
||||
full, _, _, err := winBatteryGet(0)
|
||||
if err == nil && full > 0 {
|
||||
systemHasBattery = true
|
||||
}
|
||||
if !systemHasBattery {
|
||||
slog.Debug("No battery found", "err", err)
|
||||
}
|
||||
return systemHasBattery
|
||||
})
|
||||
|
||||
// GetBatteryStats returns the current battery percent and charge state.
|
||||
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
|
||||
if !HasReadableBattery() {
|
||||
return batteryPercent, batteryState, errors.ErrUnsupported
|
||||
}
|
||||
|
||||
totalFull := uint32(0)
|
||||
totalCurrent := uint32(0)
|
||||
batteryState = math.MaxUint8
|
||||
|
||||
for i := 0; ; i++ {
|
||||
full, current, state, bErr := winBatteryGet(i)
|
||||
if errors.Is(bErr, errNotFound) {
|
||||
break
|
||||
}
|
||||
if bErr != nil || full == 0 {
|
||||
continue
|
||||
}
|
||||
totalFull += full
|
||||
totalCurrent += min(current, full)
|
||||
batteryState = state
|
||||
}
|
||||
|
||||
if totalFull == 0 || batteryState == math.MaxUint8 {
|
||||
return batteryPercent, batteryState, errors.New("no battery capacity")
|
||||
}
|
||||
|
||||
batteryPercent = uint8(float64(totalCurrent) / float64(totalFull) * 100)
|
||||
return batteryPercent, batteryState, nil
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/lxzan/gws"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -104,6 +105,11 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||
}
|
||||
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
||||
|
||||
// make sure BESZEL_AGENT_ALL_PROXY works (GWS only checks ALL_PROXY)
|
||||
if val := os.Getenv("BESZEL_AGENT_ALL_PROXY"); val != "" {
|
||||
os.Setenv("ALL_PROXY", val)
|
||||
}
|
||||
|
||||
client.options = &gws.ClientOption{
|
||||
Addr: client.hubURL.String(),
|
||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
@@ -112,6 +118,9 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
||||
"X-Token": []string{client.token},
|
||||
"X-Beszel": []string{beszel.Version},
|
||||
},
|
||||
NewDialer: func() (gws.Dialer, error) {
|
||||
return proxy.FromEnvironment(), nil
|
||||
},
|
||||
}
|
||||
return client.options
|
||||
}
|
||||
|
||||
@@ -4,11 +4,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/agent/health"
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
)
|
||||
|
||||
@@ -111,13 +115,37 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
_ = health.Update()
|
||||
case <-sigCtx.Done():
|
||||
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
||||
_ = c.agent.StopServer()
|
||||
c.closeWebSocket()
|
||||
return health.CleanUp()
|
||||
return c.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stop does not stop the connection manager itself, just any active connections. The manager will attempt to reconnect after stopping, so this should only be called immediately before shutting down the entire agent.
|
||||
//
|
||||
// If we need or want to expose a graceful Stop method in the future, do something like this to actually stop the manager:
|
||||
//
|
||||
// func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// c.cancel = cancel
|
||||
//
|
||||
// for {
|
||||
// select {
|
||||
// case <-ctx.Done():
|
||||
// return c.stop()
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// func (c *ConnectionManager) Stop() {
|
||||
// c.cancel()
|
||||
// }
|
||||
func (c *ConnectionManager) stop() error {
|
||||
_ = c.agent.StopServer()
|
||||
c.agent.probeManager.Stop()
|
||||
c.closeWebSocket()
|
||||
return health.CleanUp()
|
||||
}
|
||||
|
||||
// handleEvent processes connection events and updates the connection state accordingly.
|
||||
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||
switch event {
|
||||
@@ -185,9 +213,16 @@ func (c *ConnectionManager) connect() {
|
||||
|
||||
// Try WebSocket first, if it fails, start SSH server
|
||||
err := c.startWebSocketConnection()
|
||||
if err != nil && c.State == Disconnected {
|
||||
c.startSSHServer()
|
||||
c.startWsTicker()
|
||||
if err != nil {
|
||||
if shouldExitOnErr(err) {
|
||||
time.Sleep(2 * time.Second) // prevent tight restart loop
|
||||
_ = c.stop()
|
||||
os.Exit(1)
|
||||
}
|
||||
if c.State == Disconnected {
|
||||
c.startSSHServer()
|
||||
c.startWsTicker()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,3 +259,14 @@ func (c *ConnectionManager) closeWebSocket() {
|
||||
c.wsClient.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// shouldExitOnErr checks if the error is a DNS resolution failure and if the
|
||||
// EXIT_ON_DNS_ERROR env var is set. https://github.com/henrygd/beszel/issues/1924.
|
||||
func shouldExitOnErr(err error) bool {
|
||||
if val, _ := utils.GetEnv("EXIT_ON_DNS_ERROR"); val == "true" {
|
||||
if opErr, ok := errors.AsType[*net.OpError](err); ok {
|
||||
return strings.Contains(opErr.Err.Error(), "lookup")
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package agent
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
@@ -298,3 +299,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
|
||||
cm.connect()
|
||||
}, "Connect should not panic without WebSocket client")
|
||||
}
|
||||
|
||||
func TestShouldExitOnErr(t *testing.T) {
|
||||
createDialErr := func(msg string) error {
|
||||
return &net.OpError{
|
||||
Op: "dial",
|
||||
Net: "tcp",
|
||||
Err: errors.New(msg),
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
envValue string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "no env var",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var false",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "false",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var true, matching error",
|
||||
err: createDialErr("lookup lkahsdfasdf: no such host"),
|
||||
envValue: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "env var true, matching error with extra context",
|
||||
err: createDialErr("lookup beszel.server.lan on [::1]:53: read udp [::1]:44557->[::1]:53: read: connection refused"),
|
||||
envValue: "true",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "env var true, non-matching error",
|
||||
err: errors.New("connection refused"),
|
||||
envValue: "true",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "env var true, dial but not lookup",
|
||||
err: createDialErr("connection timeout"),
|
||||
envValue: "true",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("EXIT_ON_DNS_ERROR", tt.envValue)
|
||||
result := shouldExitOnErr(tt.err)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
108
agent/disk.go
108
agent/disk.go
@@ -34,6 +34,34 @@ type diskDiscovery struct {
|
||||
ctx fsRegistrationContext
|
||||
}
|
||||
|
||||
// prevDisk stores previous per-device disk counters for a given cache interval
|
||||
type prevDisk struct {
|
||||
readBytes uint64
|
||||
writeBytes uint64
|
||||
readTime uint64 // cumulative ms spent on reads (from ReadTime)
|
||||
writeTime uint64 // cumulative ms spent on writes (from WriteTime)
|
||||
ioTime uint64 // cumulative ms spent doing I/O (from IoTime)
|
||||
weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO)
|
||||
readCount uint64 // cumulative read operation count
|
||||
writeCount uint64 // cumulative write operation count
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t.
|
||||
func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk {
|
||||
return prevDisk{
|
||||
readBytes: d.ReadBytes,
|
||||
writeBytes: d.WriteBytes,
|
||||
readTime: d.ReadTime,
|
||||
writeTime: d.WriteTime,
|
||||
ioTime: d.IoTime,
|
||||
weightedIO: d.WeightedIO,
|
||||
readCount: d.ReadCount,
|
||||
writeCount: d.WriteCount,
|
||||
at: t,
|
||||
}
|
||||
}
|
||||
|
||||
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
|
||||
// Returns the device/filesystem part and the custom name part
|
||||
func parseFilesystemEntry(entry string) (device, customName string) {
|
||||
@@ -239,9 +267,11 @@ func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
|
||||
|
||||
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
||||
// their display names can come from the folder name while their I/O keys still
|
||||
// prefer the underlying partition device.
|
||||
// prefer the underlying partition device. Only direct children are matched to
|
||||
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
|
||||
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
|
||||
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
|
||||
if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) {
|
||||
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
|
||||
return
|
||||
}
|
||||
device, customName := extraFilesystemPartitionInfo(p)
|
||||
@@ -579,16 +609,29 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
||||
if !hasPrev {
|
||||
// Seed from agent-level fsStats if present, else seed from current
|
||||
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
|
||||
prev = prevDisk{
|
||||
readBytes: stats.TotalRead,
|
||||
writeBytes: stats.TotalWrite,
|
||||
readTime: d.ReadTime,
|
||||
writeTime: d.WriteTime,
|
||||
ioTime: d.IoTime,
|
||||
weightedIO: d.WeightedIO,
|
||||
readCount: d.ReadCount,
|
||||
writeCount: d.WriteCount,
|
||||
at: stats.Time,
|
||||
}
|
||||
if prev.at.IsZero() {
|
||||
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
prev = prevDiskFromCounter(d, now)
|
||||
}
|
||||
}
|
||||
|
||||
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
|
||||
|
||||
// Update per-interval snapshot
|
||||
a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now)
|
||||
|
||||
// Avoid division by zero or clock issues
|
||||
if msElapsed < 100 {
|
||||
// Avoid division by zero or clock issues; update snapshot and continue
|
||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -600,15 +643,38 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
// validate values
|
||||
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
||||
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
|
||||
// Reset interval snapshot and seed from current
|
||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
// also refresh agent baseline to avoid future negatives
|
||||
a.initializeDiskIoStats(ioCounters)
|
||||
continue
|
||||
}
|
||||
|
||||
// Update per-interval snapshot
|
||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
||||
// These properties are calculated differently on different platforms,
|
||||
// but generally represent cumulative time spent doing reads/writes on the device.
|
||||
// This can surpass 100% if there are multiple concurrent I/O operations.
|
||||
// Linux kernel docs:
|
||||
// This is the total number of milliseconds spent by all reads (as
|
||||
// measured from __make_request() to end_that_request_last()).
|
||||
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
|
||||
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
|
||||
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
|
||||
|
||||
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
|
||||
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
|
||||
|
||||
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
|
||||
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
|
||||
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
|
||||
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
|
||||
|
||||
// r_await / w_await: average time per read/write operation in milliseconds.
|
||||
// Equivalent to r_await and w_await in iostat.
|
||||
var rAwait, wAwait float64
|
||||
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
|
||||
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
|
||||
}
|
||||
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
|
||||
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
|
||||
}
|
||||
|
||||
// Update global fsStats baseline for cross-interval correctness
|
||||
stats.Time = now
|
||||
@@ -618,20 +684,40 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
||||
stats.DiskWritePs = writeMbPerSecond
|
||||
stats.DiskReadBytes = diskIORead
|
||||
stats.DiskWriteBytes = diskIOWrite
|
||||
stats.DiskIoStats[0] = diskReadTime
|
||||
stats.DiskIoStats[1] = diskWriteTime
|
||||
stats.DiskIoStats[2] = diskIoUtilPct
|
||||
stats.DiskIoStats[3] = rAwait
|
||||
stats.DiskIoStats[4] = wAwait
|
||||
stats.DiskIoStats[5] = diskWeightedIO
|
||||
|
||||
if stats.Root {
|
||||
systemStats.DiskReadPs = stats.DiskReadPs
|
||||
systemStats.DiskWritePs = stats.DiskWritePs
|
||||
systemStats.DiskIO[0] = diskIORead
|
||||
systemStats.DiskIO[1] = diskIOWrite
|
||||
systemStats.DiskIoStats[0] = diskReadTime
|
||||
systemStats.DiskIoStats[1] = diskWriteTime
|
||||
systemStats.DiskIoStats[2] = diskIoUtilPct
|
||||
systemStats.DiskIoStats[3] = rAwait
|
||||
systemStats.DiskIoStats[4] = wAwait
|
||||
systemStats.DiskIoStats[5] = diskWeightedIO
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// getRootMountPoint returns the appropriate root mount point for the system
|
||||
// getRootMountPoint returns the appropriate root mount point for the system.
|
||||
// On Windows it returns the system drive (e.g. "C:").
|
||||
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||
func (a *Agent) getRootMountPoint() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
if sd := os.Getenv("SystemDrive"); sd != "" {
|
||||
return sd
|
||||
}
|
||||
return "C:"
|
||||
}
|
||||
|
||||
// 1. Check if /etc/os-release contains indicators of an immutable system
|
||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||
content := string(osReleaseContent)
|
||||
|
||||
@@ -530,6 +530,87 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestAddPartitionExtraFs(t *testing.T) {
|
||||
makeDiscovery := func(agent *Agent) diskDiscovery {
|
||||
return diskDiscovery{
|
||||
agent: agent,
|
||||
ctx: fsRegistrationContext{
|
||||
isWindows: false,
|
||||
efPath: "/extra-filesystems",
|
||||
diskIoCounters: map[string]disk.IOCountersStat{
|
||||
"nvme0n1p1": {Name: "nvme0n1p1"},
|
||||
"nvme1n1": {Name: "nvme1n1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("registers direct child of extra-filesystems", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
d.addPartitionExtraFs(disk.PartitionStat{
|
||||
Device: "/dev/nvme0n1p1",
|
||||
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
|
||||
})
|
||||
|
||||
stats, exists := agent.fsStats["nvme0n1p1"]
|
||||
assert.True(t, exists)
|
||||
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
|
||||
assert.Equal(t, "caddy1-root", stats.Name)
|
||||
})
|
||||
|
||||
t.Run("skips nested mount under extra-filesystem bind mount", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
// These simulate the virtual mounts that appear when host / is bind-mounted
|
||||
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
|
||||
for _, nested := range []string{
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
|
||||
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
|
||||
} {
|
||||
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
|
||||
}
|
||||
|
||||
assert.Empty(t, agent.fsStats)
|
||||
})
|
||||
|
||||
t.Run("registers both direct children, skips their nested mounts", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
partitions := []disk.PartitionStat{
|
||||
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
|
||||
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
|
||||
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
|
||||
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
|
||||
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
|
||||
}
|
||||
for _, p := range partitions {
|
||||
d.addPartitionExtraFs(p)
|
||||
}
|
||||
|
||||
assert.Len(t, agent.fsStats, 2)
|
||||
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
|
||||
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
|
||||
})
|
||||
|
||||
t.Run("skips partition not under extra-filesystems", func(t *testing.T) {
|
||||
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
|
||||
d := makeDiscovery(agent)
|
||||
|
||||
d.addPartitionExtraFs(disk.PartitionStat{
|
||||
Device: "/dev/nvme0n1p1",
|
||||
Mountpoint: "/",
|
||||
})
|
||||
|
||||
assert.Empty(t, agent.fsStats)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFindIoDevice(t *testing.T) {
|
||||
t.Run("matches by device name", func(t *testing.T) {
|
||||
ioCounters := map[string]disk.IOCountersStat{
|
||||
|
||||
@@ -156,6 +156,7 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
||||
func readSysfsFloat(path string) (float64, error) {
|
||||
val, err := utils.ReadStringFileLimited(path, 64)
|
||||
if err != nil {
|
||||
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
||||
return 0, err
|
||||
}
|
||||
return strconv.ParseFloat(val, 64)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
|
||||
"log/slog"
|
||||
@@ -51,6 +52,7 @@ func NewHandlerRegistry() *HandlerRegistry {
|
||||
registry.Register(common.GetContainerInfo, &GetContainerInfoHandler{})
|
||||
registry.Register(common.GetSmartData, &GetSmartDataHandler{})
|
||||
registry.Register(common.GetSystemdInfo, &GetSystemdInfoHandler{})
|
||||
registry.Register(common.SyncNetworkProbes, &SyncNetworkProbesHandler{})
|
||||
|
||||
return registry
|
||||
}
|
||||
@@ -203,3 +205,19 @@ func (h *GetSystemdInfoHandler) Handle(hctx *HandlerContext) error {
|
||||
|
||||
return hctx.SendResponse(details, hctx.RequestID)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// SyncNetworkProbesHandler handles probe configuration sync from hub
|
||||
type SyncNetworkProbesHandler struct{}
|
||||
|
||||
func (h *SyncNetworkProbesHandler) Handle(hctx *HandlerContext) error {
|
||||
var configs []probe.Config
|
||||
if err := cbor.Unmarshal(hctx.Request.Data, &configs); err != nil {
|
||||
return err
|
||||
}
|
||||
hctx.Agent.probeManager.SyncProbes(configs)
|
||||
slog.Info("network probes synced", "count", len(configs))
|
||||
return hctx.SendResponse("ok", hctx.RequestID)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
|
||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
237
agent/probe.go
Normal file
237
agent/probe.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"log/slog"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
)
|
||||
|
||||
// ProbeManager manages network probe tasks.
|
||||
type ProbeManager struct {
|
||||
mu sync.RWMutex
|
||||
probes map[string]*probeTask // key = probe.Config.Key()
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
type probeTask struct {
|
||||
config probe.Config
|
||||
cancel chan struct{}
|
||||
mu sync.Mutex
|
||||
samples []probeSample
|
||||
}
|
||||
|
||||
type probeSample struct {
|
||||
latencyMs float64 // -1 means loss
|
||||
timestamp time.Time
|
||||
}
|
||||
|
||||
func newProbeManager() *ProbeManager {
|
||||
return &ProbeManager{
|
||||
probes: make(map[string]*probeTask),
|
||||
httpClient: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// SyncProbes replaces all probe tasks with the given configs.
|
||||
func (pm *ProbeManager) SyncProbes(configs []probe.Config) {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// Build set of new keys
|
||||
newKeys := make(map[string]probe.Config, len(configs))
|
||||
for _, cfg := range configs {
|
||||
newKeys[cfg.Key()] = cfg
|
||||
}
|
||||
|
||||
// Stop removed probes
|
||||
for key, task := range pm.probes {
|
||||
if _, exists := newKeys[key]; !exists {
|
||||
close(task.cancel)
|
||||
delete(pm.probes, key)
|
||||
}
|
||||
}
|
||||
|
||||
// Start new probes (skip existing ones with same key)
|
||||
for key, cfg := range newKeys {
|
||||
if _, exists := pm.probes[key]; exists {
|
||||
continue
|
||||
}
|
||||
task := &probeTask{
|
||||
config: cfg,
|
||||
cancel: make(chan struct{}),
|
||||
samples: make([]probeSample, 0, 64),
|
||||
}
|
||||
pm.probes[key] = task
|
||||
go pm.runProbe(task)
|
||||
}
|
||||
}
|
||||
|
||||
// GetResults returns aggregated results for all probes over the last 60s window.
|
||||
func (pm *ProbeManager) GetResults() map[string]probe.Result {
|
||||
pm.mu.RLock()
|
||||
defer pm.mu.RUnlock()
|
||||
|
||||
results := make(map[string]probe.Result, len(pm.probes))
|
||||
cutoff := time.Now().Add(-60 * time.Second)
|
||||
|
||||
for key, task := range pm.probes {
|
||||
task.mu.Lock()
|
||||
var sum, minMs, maxMs float64
|
||||
var count, lossCount int
|
||||
minMs = math.MaxFloat64
|
||||
|
||||
for _, s := range task.samples {
|
||||
if s.timestamp.Before(cutoff) {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if s.latencyMs < 0 {
|
||||
lossCount++
|
||||
continue
|
||||
}
|
||||
sum += s.latencyMs
|
||||
if s.latencyMs < minMs {
|
||||
minMs = s.latencyMs
|
||||
}
|
||||
if s.latencyMs > maxMs {
|
||||
maxMs = s.latencyMs
|
||||
}
|
||||
}
|
||||
task.mu.Unlock()
|
||||
|
||||
if count == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
successCount := count - lossCount
|
||||
var avg float64
|
||||
if successCount > 0 {
|
||||
avg = math.Round(sum/float64(successCount)*100) / 100
|
||||
}
|
||||
if minMs == math.MaxFloat64 {
|
||||
minMs = 0
|
||||
}
|
||||
|
||||
results[key] = probe.Result{
|
||||
avg, // average latency in ms
|
||||
math.Round(minMs*100) / 100, // min latency in ms
|
||||
math.Round(maxMs*100) / 100, // max latency in ms
|
||||
math.Round(float64(lossCount)/float64(count)*10000) / 100, // packet loss percentage
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// Stop stops all probe tasks.
|
||||
func (pm *ProbeManager) Stop() {
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
for key, task := range pm.probes {
|
||||
close(task.cancel)
|
||||
delete(pm.probes, key)
|
||||
}
|
||||
}
|
||||
|
||||
// runProbe executes a single probe task in a loop.
|
||||
func (pm *ProbeManager) runProbe(task *probeTask) {
|
||||
interval := time.Duration(task.config.Interval) * time.Second
|
||||
if interval < time.Second {
|
||||
interval = 10 * time.Second
|
||||
}
|
||||
ticker := time.Tick(interval)
|
||||
|
||||
// Run immediately on start
|
||||
pm.executeProbe(task)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-task.cancel:
|
||||
return
|
||||
case <-ticker:
|
||||
pm.executeProbe(task)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pm *ProbeManager) executeProbe(task *probeTask) {
|
||||
var latencyMs float64
|
||||
|
||||
switch task.config.Protocol {
|
||||
case "icmp":
|
||||
latencyMs = probeICMP(task.config.Target)
|
||||
case "tcp":
|
||||
latencyMs = probeTCP(task.config.Target, task.config.Port)
|
||||
case "http":
|
||||
latencyMs = probeHTTP(pm.httpClient, task.config.Target)
|
||||
default:
|
||||
slog.Warn("unknown probe protocol", "protocol", task.config.Protocol)
|
||||
return
|
||||
}
|
||||
|
||||
sample := probeSample{
|
||||
latencyMs: latencyMs,
|
||||
timestamp: time.Now(),
|
||||
}
|
||||
|
||||
task.mu.Lock()
|
||||
// Trim old samples beyond 120s to bound memory
|
||||
cutoff := time.Now().Add(-120 * time.Second)
|
||||
start := 0
|
||||
for i := range task.samples {
|
||||
if task.samples[i].timestamp.After(cutoff) {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
if i == len(task.samples)-1 {
|
||||
start = len(task.samples)
|
||||
}
|
||||
}
|
||||
if start > 0 {
|
||||
size := copy(task.samples, task.samples[start:])
|
||||
task.samples = task.samples[:size]
|
||||
}
|
||||
task.samples = append(task.samples, sample)
|
||||
task.mu.Unlock()
|
||||
}
|
||||
|
||||
// probeTCP measures pure TCP handshake latency (excluding DNS resolution).
|
||||
// Returns -1 on failure.
|
||||
func probeTCP(target string, port uint16) float64 {
|
||||
// Resolve DNS first, outside the timing window
|
||||
ips, err := net.LookupHost(target)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return -1
|
||||
}
|
||||
addr := net.JoinHostPort(ips[0], fmt.Sprintf("%d", port))
|
||||
|
||||
// Measure only the TCP handshake
|
||||
start := time.Now()
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
conn.Close()
|
||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||
}
|
||||
|
||||
// probeHTTP measures HTTP GET request latency. Returns -1 on failure.
|
||||
func probeHTTP(client *http.Client, url string) float64 {
|
||||
start := time.Now()
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return -1
|
||||
}
|
||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||
}
|
||||
242
agent/probe_ping.go
Normal file
242
agent/probe_ping.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
"golang.org/x/net/ipv6"
|
||||
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var pingTimeRegex = regexp.MustCompile(`time[=<]([\d.]+)\s*ms`)
|
||||
|
||||
type icmpPacketConn interface {
|
||||
Close() error
|
||||
}
|
||||
|
||||
// icmpMethod tracks which ICMP approach to use. Once a method succeeds or
|
||||
// all native methods fail, the choice is cached so subsequent probes skip
|
||||
// the trial-and-error overhead.
|
||||
type icmpMethod int
|
||||
|
||||
const (
|
||||
icmpUntried icmpMethod = iota // haven't tried yet
|
||||
icmpRaw // privileged raw socket
|
||||
icmpDatagram // unprivileged datagram socket
|
||||
icmpExecFallback // shell out to system ping command
|
||||
)
|
||||
|
||||
// icmpFamily holds the network parameters and cached detection result for one address family.
|
||||
type icmpFamily struct {
|
||||
rawNetwork string // e.g. "ip4:icmp" or "ip6:ipv6-icmp"
|
||||
dgramNetwork string // e.g. "udp4" or "udp6"
|
||||
listenAddr string // "0.0.0.0" or "::"
|
||||
echoType icmp.Type // outgoing echo request type
|
||||
replyType icmp.Type // expected echo reply type
|
||||
proto int // IANA protocol number for parsing replies
|
||||
isIPv6 bool
|
||||
mode icmpMethod // cached detection result (guarded by icmpModeMu)
|
||||
}
|
||||
|
||||
var (
|
||||
icmpV4 = icmpFamily{
|
||||
rawNetwork: "ip4:icmp",
|
||||
dgramNetwork: "udp4",
|
||||
listenAddr: "0.0.0.0",
|
||||
echoType: ipv4.ICMPTypeEcho,
|
||||
replyType: ipv4.ICMPTypeEchoReply,
|
||||
proto: 1,
|
||||
}
|
||||
icmpV6 = icmpFamily{
|
||||
rawNetwork: "ip6:ipv6-icmp",
|
||||
dgramNetwork: "udp6",
|
||||
listenAddr: "::",
|
||||
echoType: ipv6.ICMPTypeEchoRequest,
|
||||
replyType: ipv6.ICMPTypeEchoReply,
|
||||
proto: 58,
|
||||
isIPv6: true,
|
||||
}
|
||||
icmpModeMu sync.Mutex
|
||||
icmpListen = func(network, listenAddr string) (icmpPacketConn, error) {
|
||||
return icmp.ListenPacket(network, listenAddr)
|
||||
}
|
||||
)
|
||||
|
||||
// probeICMP sends an ICMP echo request and measures round-trip latency.
|
||||
// Supports both IPv4 and IPv6 targets. The ICMP method (raw socket,
|
||||
// unprivileged datagram, or exec fallback) is detected once per address
|
||||
// family and cached for subsequent probes.
|
||||
// Returns latency in milliseconds, or -1 on failure.
|
||||
func probeICMP(target string) float64 {
|
||||
family, ip := resolveICMPTarget(target)
|
||||
if family == nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
icmpModeMu.Lock()
|
||||
if family.mode == icmpUntried {
|
||||
family.mode = detectICMPMode(family, icmpListen)
|
||||
}
|
||||
mode := family.mode
|
||||
icmpModeMu.Unlock()
|
||||
|
||||
switch mode {
|
||||
case icmpRaw:
|
||||
return probeICMPNative(family.rawNetwork, family, &net.IPAddr{IP: ip})
|
||||
case icmpDatagram:
|
||||
return probeICMPNative(family.dgramNetwork, family, &net.UDPAddr{IP: ip})
|
||||
case icmpExecFallback:
|
||||
return probeICMPExec(target, family.isIPv6)
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
// resolveICMPTarget resolves a target hostname or IP to determine the address
|
||||
// family and concrete IP address. Prefers IPv4 for dual-stack hostnames.
|
||||
func resolveICMPTarget(target string) (*icmpFamily, net.IP) {
|
||||
if ip := net.ParseIP(target); ip != nil {
|
||||
if ip.To4() != nil {
|
||||
return &icmpV4, ip.To4()
|
||||
}
|
||||
return &icmpV6, ip
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(target)
|
||||
if err != nil || len(ips) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
for _, ip := range ips {
|
||||
if v4 := ip.To4(); v4 != nil {
|
||||
return &icmpV4, v4
|
||||
}
|
||||
}
|
||||
return &icmpV6, ips[0]
|
||||
}
|
||||
|
||||
func detectICMPMode(family *icmpFamily, listen func(network, listenAddr string) (icmpPacketConn, error)) icmpMethod {
|
||||
label := "IPv4"
|
||||
if family.isIPv6 {
|
||||
label = "IPv6"
|
||||
}
|
||||
|
||||
if conn, err := listen(family.rawNetwork, family.listenAddr); err == nil {
|
||||
conn.Close()
|
||||
slog.Info("ICMP probe using raw socket", "family", label)
|
||||
return icmpRaw
|
||||
} else {
|
||||
slog.Debug("ICMP raw socket unavailable", "family", label, "err", err)
|
||||
}
|
||||
|
||||
if conn, err := listen(family.dgramNetwork, family.listenAddr); err == nil {
|
||||
conn.Close()
|
||||
slog.Info("ICMP probe using unprivileged datagram socket", "family", label)
|
||||
return icmpDatagram
|
||||
} else {
|
||||
slog.Debug("ICMP datagram socket unavailable", "family", label, "err", err)
|
||||
}
|
||||
|
||||
slog.Info("ICMP probe falling back to system ping command", "family", label)
|
||||
return icmpExecFallback
|
||||
}
|
||||
|
||||
// probeICMPNative sends an ICMP echo request using Go's x/net/icmp package.
|
||||
func probeICMPNative(network string, family *icmpFamily, dst net.Addr) float64 {
|
||||
conn, err := icmp.ListenPacket(network, family.listenAddr)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Build ICMP echo request
|
||||
msg := &icmp.Message{
|
||||
Type: family.echoType,
|
||||
Code: 0,
|
||||
Body: &icmp.Echo{
|
||||
ID: os.Getpid() & 0xffff,
|
||||
Seq: 1,
|
||||
Data: []byte("beszel-probe"),
|
||||
},
|
||||
}
|
||||
msgBytes, err := msg.Marshal(nil)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Set deadline before sending
|
||||
conn.SetDeadline(time.Now().Add(3 * time.Second))
|
||||
|
||||
start := time.Now()
|
||||
if _, err := conn.WriteTo(msgBytes, dst); err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
// Read reply
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
reply, err := icmp.ParseMessage(family.proto, buf[:n])
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
|
||||
if reply.Type == family.replyType {
|
||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||
}
|
||||
// Ignore non-echo-reply messages (e.g. destination unreachable) and keep reading
|
||||
}
|
||||
}
|
||||
|
||||
// probeICMPExec falls back to the system ping command. Returns -1 on failure.
|
||||
func probeICMPExec(target string, isIPv6 bool) float64 {
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
if isIPv6 {
|
||||
cmd = exec.Command("ping", "-6", "-n", "1", "-w", "3000", target)
|
||||
} else {
|
||||
cmd = exec.Command("ping", "-n", "1", "-w", "3000", target)
|
||||
}
|
||||
default: // linux, darwin, freebsd
|
||||
if isIPv6 {
|
||||
cmd = exec.Command("ping", "-6", "-c", "1", "-W", "3", target)
|
||||
} else {
|
||||
cmd = exec.Command("ping", "-c", "1", "-W", "3", target)
|
||||
}
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// If ping fails but we got output, still try to parse
|
||||
if len(output) == 0 {
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
matches := pingTimeRegex.FindSubmatch(output)
|
||||
if len(matches) >= 2 {
|
||||
if ms, err := strconv.ParseFloat(string(matches[1]), 64); err == nil {
|
||||
return ms
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use wall clock time if ping succeeded but parsing failed
|
||||
if err == nil {
|
||||
return float64(time.Since(start).Microseconds()) / 1000.0
|
||||
}
|
||||
return -1
|
||||
}
|
||||
118
agent/probe_ping_test.go
Normal file
118
agent/probe_ping_test.go
Normal file
@@ -0,0 +1,118 @@
|
||||
//go:build testing
|
||||
|
||||
package agent
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type testICMPPacketConn struct{}
|
||||
|
||||
func (testICMPPacketConn) Close() error { return nil }
|
||||
|
||||
func TestDetectICMPMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
family *icmpFamily
|
||||
rawErr error
|
||||
udpErr error
|
||||
want icmpMethod
|
||||
wantNetworks []string
|
||||
}{
|
||||
{
|
||||
name: "IPv4 prefers raw socket when available",
|
||||
family: &icmpV4,
|
||||
want: icmpRaw,
|
||||
wantNetworks: []string{"ip4:icmp"},
|
||||
},
|
||||
{
|
||||
name: "IPv4 uses datagram when raw unavailable",
|
||||
family: &icmpV4,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
want: icmpDatagram,
|
||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||
},
|
||||
{
|
||||
name: "IPv4 falls back to exec when both unavailable",
|
||||
family: &icmpV4,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
udpErr: errors.New("protocol not supported"),
|
||||
want: icmpExecFallback,
|
||||
wantNetworks: []string{"ip4:icmp", "udp4"},
|
||||
},
|
||||
{
|
||||
name: "IPv6 prefers raw socket when available",
|
||||
family: &icmpV6,
|
||||
want: icmpRaw,
|
||||
wantNetworks: []string{"ip6:ipv6-icmp"},
|
||||
},
|
||||
{
|
||||
name: "IPv6 uses datagram when raw unavailable",
|
||||
family: &icmpV6,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
want: icmpDatagram,
|
||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||
},
|
||||
{
|
||||
name: "IPv6 falls back to exec when both unavailable",
|
||||
family: &icmpV6,
|
||||
rawErr: errors.New("operation not permitted"),
|
||||
udpErr: errors.New("protocol not supported"),
|
||||
want: icmpExecFallback,
|
||||
wantNetworks: []string{"ip6:ipv6-icmp", "udp6"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
calls := make([]string, 0, 2)
|
||||
listen := func(network, listenAddr string) (icmpPacketConn, error) {
|
||||
require.Equal(t, tt.family.listenAddr, listenAddr)
|
||||
calls = append(calls, network)
|
||||
switch network {
|
||||
case tt.family.rawNetwork:
|
||||
if tt.rawErr != nil {
|
||||
return nil, tt.rawErr
|
||||
}
|
||||
case tt.family.dgramNetwork:
|
||||
if tt.udpErr != nil {
|
||||
return nil, tt.udpErr
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unexpected network %q", network)
|
||||
}
|
||||
return testICMPPacketConn{}, nil
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.want, detectICMPMode(tt.family, listen))
|
||||
assert.Equal(t, tt.wantNetworks, calls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveICMPTarget(t *testing.T) {
|
||||
t.Run("IPv4 literal", func(t *testing.T) {
|
||||
family, ip := resolveICMPTarget("127.0.0.1")
|
||||
require.NotNil(t, family)
|
||||
assert.False(t, family.isIPv6)
|
||||
assert.Equal(t, "127.0.0.1", ip.String())
|
||||
})
|
||||
|
||||
t.Run("IPv6 literal", func(t *testing.T) {
|
||||
family, ip := resolveICMPTarget("::1")
|
||||
require.NotNil(t, family)
|
||||
assert.True(t, family.isIPv6)
|
||||
assert.Equal(t, "::1", ip.String())
|
||||
})
|
||||
|
||||
t.Run("IPv4-mapped IPv6 resolves as IPv4", func(t *testing.T) {
|
||||
family, ip := resolveICMPTarget("::ffff:127.0.0.1")
|
||||
require.NotNil(t, family)
|
||||
assert.False(t, family.isIPv6)
|
||||
assert.Equal(t, "127.0.0.1", ip.String())
|
||||
})
|
||||
}
|
||||
@@ -19,13 +19,20 @@ import (
|
||||
"github.com/shirou/gopsutil/v4/sensors"
|
||||
)
|
||||
|
||||
var errTemperatureFetchTimeout = errors.New("temperature collection timed out")
|
||||
|
||||
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
||||
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
||||
|
||||
type SensorConfig struct {
|
||||
context context.Context
|
||||
sensors map[string]struct{}
|
||||
primarySensor string
|
||||
timeout time.Duration
|
||||
isBlacklist bool
|
||||
hasWildcards bool
|
||||
skipCollection bool
|
||||
firstRun bool
|
||||
}
|
||||
|
||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||
@@ -33,25 +40,29 @@ func (a *Agent) newSensorConfig() *SensorConfig {
|
||||
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
|
||||
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
||||
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
|
||||
|
||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
|
||||
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
|
||||
}
|
||||
|
||||
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
|
||||
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
|
||||
|
||||
var (
|
||||
errTemperatureFetchTimeout = errors.New("temperature collection timed out")
|
||||
temperatureFetchTimeout = 2 * time.Second
|
||||
)
|
||||
|
||||
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
|
||||
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
|
||||
timeout := 2 * time.Second
|
||||
if sensorsTimeout != "" {
|
||||
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
|
||||
timeout = d
|
||||
} else {
|
||||
slog.Warn("Invalid SENSORS_TIMEOUT", "value", sensorsTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
config := &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: primarySensor,
|
||||
timeout: timeout,
|
||||
skipCollection: skipCollection,
|
||||
firstRun: true,
|
||||
sensors: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
@@ -167,6 +178,14 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
||||
err error
|
||||
}
|
||||
|
||||
// Use a longer timeout on the first run to allow for initialization
|
||||
// (e.g. Windows LHM subprocess startup)
|
||||
timeout := a.sensorConfig.timeout
|
||||
if a.sensorConfig.firstRun {
|
||||
a.sensorConfig.firstRun = false
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
resultCh := make(chan result, 1)
|
||||
go func() {
|
||||
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
||||
@@ -176,7 +195,7 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
||||
select {
|
||||
case res := <-resultCh:
|
||||
return res.temps, res.err
|
||||
case <-time.After(temperatureFetchTimeout):
|
||||
case <-time.After(timeout):
|
||||
return nil, errTemperatureFetchTimeout
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
primarySensor string
|
||||
sysSensors string
|
||||
sensors string
|
||||
sensorsTimeout string
|
||||
skipCollection bool
|
||||
expectedConfig *SensorConfig
|
||||
}{
|
||||
@@ -179,12 +180,37 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
skipCollection: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Custom timeout",
|
||||
primarySensor: "",
|
||||
sysSensors: "",
|
||||
sensors: "",
|
||||
sensorsTimeout: "5s",
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 5 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Invalid timeout falls back to default",
|
||||
primarySensor: "",
|
||||
sysSensors: "",
|
||||
sensors: "",
|
||||
sensorsTimeout: "notaduration",
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Explicitly set to empty string",
|
||||
primarySensor: "",
|
||||
@@ -194,6 +220,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
@@ -208,6 +235,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{},
|
||||
isBlacklist: false,
|
||||
hasWildcards: false,
|
||||
@@ -221,6 +249,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_temp": {},
|
||||
"gpu_temp": {},
|
||||
@@ -237,6 +266,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_temp": {},
|
||||
"gpu_temp": {},
|
||||
@@ -253,6 +283,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_*": {},
|
||||
"gpu_temp": {},
|
||||
@@ -269,6 +300,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
expectedConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_*": {},
|
||||
"gpu_temp": {},
|
||||
@@ -284,6 +316,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
sensors: "cpu_temp",
|
||||
expectedConfig: &SensorConfig{
|
||||
primarySensor: "cpu_temp",
|
||||
timeout: 2 * time.Second,
|
||||
sensors: map[string]struct{}{
|
||||
"cpu_temp": {},
|
||||
},
|
||||
@@ -295,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
|
||||
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
|
||||
|
||||
// Check primary sensor
|
||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||
@@ -314,6 +347,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
||||
// Check flags
|
||||
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
||||
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
||||
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
|
||||
|
||||
// Check context
|
||||
if tt.sysSensors != "" {
|
||||
@@ -333,12 +367,14 @@ func TestNewSensorConfig(t *testing.T) {
|
||||
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
|
||||
|
||||
agent := &Agent{}
|
||||
result := agent.newSensorConfig()
|
||||
|
||||
// Verify results
|
||||
assert.Equal(t, "test_primary", result.primarySensor)
|
||||
assert.Equal(t, 7*time.Second, result.timeout)
|
||||
assert.NotNil(t, result.sensors)
|
||||
assert.Equal(t, 3, len(result.sensors))
|
||||
assert.True(t, result.hasWildcards)
|
||||
@@ -532,15 +568,10 @@ func TestGetTempsWithTimeout(t *testing.T) {
|
||||
agent := &Agent{
|
||||
sensorConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
originalTimeout := temperatureFetchTimeout
|
||||
t.Cleanup(func() {
|
||||
temperatureFetchTimeout = originalTimeout
|
||||
})
|
||||
temperatureFetchTimeout = 10 * time.Millisecond
|
||||
|
||||
t.Run("returns temperatures before timeout", func(t *testing.T) {
|
||||
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
||||
@@ -567,15 +598,13 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
||||
systemInfo: system.Info{DashboardTemp: 99},
|
||||
sensorConfig: &SensorConfig{
|
||||
context: context.Background(),
|
||||
timeout: 10 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
|
||||
originalTimeout := temperatureFetchTimeout
|
||||
t.Cleanup(func() {
|
||||
temperatureFetchTimeout = originalTimeout
|
||||
getSensorTemps = sensors.TemperaturesWithContext
|
||||
})
|
||||
temperatureFetchTimeout = 10 * time.Millisecond
|
||||
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
return nil, nil
|
||||
|
||||
@@ -25,12 +25,15 @@ import (
|
||||
// SmartManager manages data collection for SMART devices
|
||||
type SmartManager struct {
|
||||
sync.Mutex
|
||||
SmartDataMap map[string]*smart.SmartData
|
||||
SmartDevices []*DeviceInfo
|
||||
refreshMutex sync.Mutex
|
||||
lastScanTime time.Time
|
||||
smartctlPath string
|
||||
excludedDevices map[string]struct{}
|
||||
SmartDataMap map[string]*smart.SmartData
|
||||
SmartDevices []*DeviceInfo
|
||||
refreshMutex sync.Mutex
|
||||
lastScanTime time.Time
|
||||
smartctlPath string
|
||||
excludedDevices map[string]struct{}
|
||||
darwinNvmeOnce sync.Once
|
||||
darwinNvmeCapacity map[string]uint64 // serial → bytes cache, written once via darwinNvmeOnce
|
||||
darwinNvmeProvider func() ([]byte, error) // overridable for testing
|
||||
}
|
||||
|
||||
type scanOutput struct {
|
||||
@@ -1033,6 +1036,52 @@ func parseScsiGigabytesProcessed(value string) int64 {
|
||||
return parsed
|
||||
}
|
||||
|
||||
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
|
||||
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
|
||||
// via smartctl. Results are cached after the first call via sync.Once.
|
||||
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
|
||||
sm.darwinNvmeOnce.Do(func() {
|
||||
sm.darwinNvmeCapacity = make(map[string]uint64)
|
||||
|
||||
provider := sm.darwinNvmeProvider
|
||||
if provider == nil {
|
||||
provider = func() ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
|
||||
}
|
||||
}
|
||||
|
||||
out, err := provider()
|
||||
if err != nil {
|
||||
slog.Debug("system_profiler NVMe lookup failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
SPNVMeDataType []struct {
|
||||
Items []struct {
|
||||
DeviceSerial string `json:"device_serial"`
|
||||
SizeInBytes uint64 `json:"size_in_bytes"`
|
||||
} `json:"_items"`
|
||||
} `json:"SPNVMeDataType"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
slog.Debug("system_profiler NVMe parse failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, controller := range result.SPNVMeDataType {
|
||||
for _, item := range controller.Items {
|
||||
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
|
||||
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return sm.darwinNvmeCapacity[serial]
|
||||
}
|
||||
|
||||
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||
// Returns hasValidData and exitStatus
|
||||
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
@@ -1069,6 +1118,12 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
||||
smartData.SerialNumber = data.SerialNumber
|
||||
smartData.FirmwareVersion = data.FirmwareVersion
|
||||
smartData.Capacity = data.UserCapacity.Bytes
|
||||
if smartData.Capacity == 0 {
|
||||
smartData.Capacity = data.NVMeTotalCapacity
|
||||
}
|
||||
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
||||
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
||||
}
|
||||
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||
smartData.DiskName = data.Device.Name
|
||||
|
||||
@@ -1199,3 +1199,81 @@ func TestIsNvmeControllerPath(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartForNvmeAppleSSD(t *testing.T) {
|
||||
// Apple SSDs don't report user_capacity via smartctl; capacity should be fetched
|
||||
// from system_profiler via the darwinNvmeProvider fallback.
|
||||
fixturePath := filepath.Join("test-data", "smart", "apple_nvme.json")
|
||||
data, err := os.ReadFile(fixturePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
providerCalls := 0
|
||||
fakeProvider := func() ([]byte, error) {
|
||||
providerCalls++
|
||||
return []byte(`{
|
||||
"SPNVMeDataType": [{
|
||||
"_items": [{
|
||||
"device_serial": "0ba0147940253c15",
|
||||
"size_in_bytes": 251000193024
|
||||
}]
|
||||
}]
|
||||
}`), nil
|
||||
}
|
||||
|
||||
sm := &SmartManager{
|
||||
SmartDataMap: make(map[string]*smart.SmartData),
|
||||
darwinNvmeProvider: fakeProvider,
|
||||
}
|
||||
|
||||
hasData, _ := sm.parseSmartForNvme(data)
|
||||
require.True(t, hasData)
|
||||
|
||||
deviceData, ok := sm.SmartDataMap["0ba0147940253c15"]
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "APPLE SSD AP0256Q", deviceData.ModelName)
|
||||
assert.Equal(t, uint64(251000193024), deviceData.Capacity)
|
||||
assert.Equal(t, uint8(42), deviceData.Temperature)
|
||||
assert.Equal(t, "PASSED", deviceData.SmartStatus)
|
||||
assert.Equal(t, 1, providerCalls, "system_profiler should be called once")
|
||||
|
||||
// Second parse: provider should NOT be called again (cache hit)
|
||||
_, _ = sm.parseSmartForNvme(data)
|
||||
assert.Equal(t, 1, providerCalls, "system_profiler should not be called again after caching")
|
||||
}
|
||||
|
||||
func TestLookupDarwinNvmeCapacityMultipleDisks(t *testing.T) {
|
||||
fakeProvider := func() ([]byte, error) {
|
||||
return []byte(`{
|
||||
"SPNVMeDataType": [
|
||||
{
|
||||
"_items": [
|
||||
{"device_serial": "serial-disk0", "size_in_bytes": 251000193024},
|
||||
{"device_serial": "serial-disk1", "size_in_bytes": 1000204886016}
|
||||
]
|
||||
},
|
||||
{
|
||||
"_items": [
|
||||
{"device_serial": "serial-disk2", "size_in_bytes": 512110190592}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`), nil
|
||||
}
|
||||
|
||||
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
||||
assert.Equal(t, uint64(251000193024), sm.lookupDarwinNvmeCapacity("serial-disk0"))
|
||||
assert.Equal(t, uint64(1000204886016), sm.lookupDarwinNvmeCapacity("serial-disk1"))
|
||||
assert.Equal(t, uint64(512110190592), sm.lookupDarwinNvmeCapacity("serial-disk2"))
|
||||
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("unknown-serial"))
|
||||
}
|
||||
|
||||
func TestLookupDarwinNvmeCapacityProviderError(t *testing.T) {
|
||||
fakeProvider := func() ([]byte, error) {
|
||||
return nil, errors.New("system_profiler not found")
|
||||
}
|
||||
|
||||
sm := &SmartManager{darwinNvmeProvider: fakeProvider}
|
||||
assert.Equal(t, uint64(0), sm.lookupDarwinNvmeCapacity("any-serial"))
|
||||
// Cache should be initialized even on error so we don't retry (Once already fired)
|
||||
assert.NotNil(t, sm.darwinNvmeCapacity)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/agent/battery"
|
||||
@@ -23,13 +22,6 @@ import (
|
||||
"github.com/shirou/gopsutil/v4/mem"
|
||||
)
|
||||
|
||||
// prevDisk stores previous per-device disk counters for a given cache interval
|
||||
type prevDisk struct {
|
||||
readBytes uint64
|
||||
writeBytes uint64
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// Sets initial / non-changing values about the host system
|
||||
func (a *Agent) refreshSystemDetails() {
|
||||
a.systemInfo.AgentVersion = beszel.Version
|
||||
|
||||
51
agent/test-data/smart/apple_nvme.json
Normal file
51
agent/test-data/smart/apple_nvme.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"json_format_version": [1, 0],
|
||||
"smartctl": {
|
||||
"version": [7, 4],
|
||||
"argv": ["smartctl", "-aix", "-j", "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1"],
|
||||
"exit_status": 4
|
||||
},
|
||||
"device": {
|
||||
"name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
|
||||
"info_name": "IOService:/AppleARMPE/arm-io@10F00000/AppleT810xIO/ans@77400000/AppleASCWrapV4/iop-ans-nub/RTBuddy(ANS2)/RTBuddyService/AppleANS3NVMeController/NS_01@1",
|
||||
"type": "nvme",
|
||||
"protocol": "NVMe"
|
||||
},
|
||||
"model_name": "APPLE SSD AP0256Q",
|
||||
"serial_number": "0ba0147940253c15",
|
||||
"firmware_version": "555",
|
||||
"smart_support": {
|
||||
"available": true,
|
||||
"enabled": true
|
||||
},
|
||||
"smart_status": {
|
||||
"passed": true,
|
||||
"nvme": {
|
||||
"value": 0
|
||||
}
|
||||
},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0,
|
||||
"temperature": 42,
|
||||
"available_spare": 100,
|
||||
"available_spare_threshold": 99,
|
||||
"percentage_used": 1,
|
||||
"data_units_read": 270189386,
|
||||
"data_units_written": 166753862,
|
||||
"host_reads": 7543766995,
|
||||
"host_writes": 3761621926,
|
||||
"controller_busy_time": 0,
|
||||
"power_cycles": 366,
|
||||
"power_on_hours": 2850,
|
||||
"unsafe_shutdowns": 195,
|
||||
"media_errors": 0,
|
||||
"num_err_log_entries": 0
|
||||
},
|
||||
"temperature": {
|
||||
"current": 42
|
||||
},
|
||||
"power_cycle_count": 366,
|
||||
"power_on_time": {
|
||||
"hours": 2850
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
// Package utils provides utility functions for the agent.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
@@ -68,6 +70,9 @@ func ReadStringFileLimited(path string, maxSize int) (string, error) {
|
||||
if err != nil && err != io.EOF {
|
||||
return "", err
|
||||
}
|
||||
if n < 0 {
|
||||
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
|
||||
}
|
||||
return strings.TrimSpace(string(buf[:n])), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
||||
|
||||
const (
|
||||
// Version is the current version of the application.
|
||||
Version = "0.18.6"
|
||||
Version = "0.18.7"
|
||||
// AppName is the name of the application.
|
||||
AppName = "beszel"
|
||||
)
|
||||
|
||||
13
go.mod
13
go.mod
@@ -5,24 +5,25 @@ go 1.26.1
|
||||
require (
|
||||
github.com/blang/semver v3.5.1+incompatible
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/distatus/battery v0.11.0
|
||||
github.com/ebitengine/purego v0.10.0
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/gliderlabs/ssh v0.3.8
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/lxzan/gws v1.9.1
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.1
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.3
|
||||
github.com/pocketbase/dbx v1.12.0
|
||||
github.com/pocketbase/pocketbase v0.36.7
|
||||
github.com/shirou/gopsutil/v4 v4.26.2
|
||||
github.com/pocketbase/pocketbase v0.36.8
|
||||
github.com/shirou/gopsutil/v4 v4.26.3
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/crypto v0.49.0
|
||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||
golang.org/x/net v0.52.0
|
||||
golang.org/x/sys v0.42.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
howett.net/plist v1.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -56,14 +57,12 @@ require (
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
golang.org/x/image v0.38.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/term v0.41.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
howett.net/plist v1.0.1 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.2 // indirect
|
||||
modernc.org/sqlite v1.48.0 // indirect
|
||||
)
|
||||
|
||||
18
go.sum
18
go.sum
@@ -17,8 +17,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
|
||||
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
@@ -87,8 +85,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.1 h1:6sx4cJNfNuUtD6ygGlB0dqcCQ+abfsUh+b+6jgujf6A=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.1/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig=
|
||||
github.com/nicholas-fedor/shoutrrr v0.14.3/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
||||
@@ -98,8 +96,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
||||
github.com/pocketbase/pocketbase v0.36.7 h1:MrViB7BptPYrf2Nt25pJEYBqUdFjuhRKu1p5GTrkvPA=
|
||||
github.com/pocketbase/pocketbase v0.36.7/go.mod h1:qX4HuVjoKXtEg41fSJVM0JLfGWXbBmHxVv/FaE446r4=
|
||||
github.com/pocketbase/pocketbase v0.36.8 h1:gCNqoesZ44saYOD3J7edhi5nDwUWKyQG7boM/kVwz2c=
|
||||
github.com/pocketbase/pocketbase v0.36.8/go.mod h1:OY4WaXbP0WnF/EXoBbboWJK+ZSZ1A85tiA0sjrTKxTA=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
@@ -107,8 +105,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
@@ -199,8 +197,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
|
||||
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
||||
@@ -302,21 +302,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
err := e.BindBody(&data)
|
||||
if err != nil || data.URL == "" {
|
||||
return e.BadRequestError("URL is required", err)
|
||||
}
|
||||
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||
if err != nil {
|
||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||
}
|
||||
return e.JSON(200, map[string]bool{"err": false})
|
||||
}
|
||||
|
||||
// setAlertTriggered updates the "triggered" status of an alert record in the database
|
||||
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
|
||||
|
||||
@@ -3,7 +3,11 @@ package alerts
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -117,3 +121,72 @@ func DeleteUserAlerts(e *core.RequestEvent) error {
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]any{"success": true, "count": numDeleted})
|
||||
}
|
||||
|
||||
// SendTestNotification handles API request to send a test notification to a specified Shoutrrr URL
|
||||
func (am *AlertManager) SendTestNotification(e *core.RequestEvent) error {
|
||||
var data struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
err := e.BindBody(&data)
|
||||
if err != nil || data.URL == "" {
|
||||
return e.BadRequestError("URL is required", err)
|
||||
}
|
||||
// Only allow admins to send test notifications to internal URLs
|
||||
if !e.Auth.IsSuperuser() && e.Auth.GetString("role") != "admin" {
|
||||
internalURL, err := isInternalURL(data.URL)
|
||||
if err != nil {
|
||||
return e.BadRequestError(err.Error(), nil)
|
||||
}
|
||||
if internalURL {
|
||||
return e.ForbiddenError("Only admins can send to internal destinations", nil)
|
||||
}
|
||||
}
|
||||
err = am.SendShoutrrrAlert(data.URL, "Test Alert", "This is a notification from Beszel.", am.hub.Settings().Meta.AppURL, "View Beszel")
|
||||
if err != nil {
|
||||
return e.JSON(200, map[string]string{"err": err.Error()})
|
||||
}
|
||||
return e.JSON(200, map[string]bool{"err": false})
|
||||
}
|
||||
|
||||
// isInternalURL checks if the given shoutrrr URL points to an internal destination (localhost or private IP)
|
||||
func isInternalURL(rawURL string) (bool, error) {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if strings.EqualFold(host, "localhost") {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return isInternalIP(ip), nil
|
||||
}
|
||||
|
||||
// Some Shoutrrr URLs use the host position for service identifiers rather than a
|
||||
// network hostname (for example, discord://token@webhookid). Restrict DNS lookups
|
||||
// to names that look like actual hostnames so valid service URLs keep working.
|
||||
if !strings.Contains(host, ".") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ips, err := net.LookupIP(host)
|
||||
if err != nil {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if slices.ContainsFunc(ips, isInternalIP) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func isInternalIP(ip net.IP) bool {
|
||||
return ip.IsPrivate() || ip.IsLoopback() || ip.IsUnspecified()
|
||||
}
|
||||
|
||||
501
internal/alerts/alerts_api_test.go
Normal file
501
internal/alerts/alerts_api_test.go
Normal file
@@ -0,0 +1,501 @@
|
||||
//go:build testing
|
||||
|
||||
package alerts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
beszelTests "github.com/henrygd/beszel/internal/tests"
|
||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||
func jsonReader(v any) io.Reader {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
func TestIsInternalURL(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
url string
|
||||
internal bool
|
||||
}{
|
||||
{name: "loopback ipv4", url: "generic://127.0.0.1", internal: true},
|
||||
{name: "localhost hostname", url: "generic://localhost", internal: true},
|
||||
{name: "localhost hostname", url: "generic+http://localhost/api/v1/postStuff", internal: true},
|
||||
{name: "localhost hostname", url: "generic+http://127.0.0.1:8080/api/v1/postStuff", internal: true},
|
||||
{name: "localhost hostname", url: "generic+https://beszel.dev/api/v1/postStuff", internal: false},
|
||||
{name: "public ipv4", url: "generic://8.8.8.8", internal: false},
|
||||
{name: "token style service url", url: "discord://abc123@123456789", internal: false},
|
||||
{name: "single label service url", url: "slack://token@team/channel", internal: false},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
internal, err := alerts.IsInternalURL(testCase.url)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, testCase.internal, internal)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserAlertsApi(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||
user1Token, _ := user1.NewAuthToken()
|
||||
|
||||
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||
user2Token, _ := user2.NewAuthToken()
|
||||
|
||||
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system1",
|
||||
"users": []string{user1.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
|
||||
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system2",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
"host": "127.0.0.2",
|
||||
})
|
||||
|
||||
userRecords, _ := hub.CountRecords("users")
|
||||
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||
|
||||
systemRecords, _ := hub.CountRecords("systems")
|
||||
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
// {
|
||||
// Name: "GET not implemented - returns index",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/beszel/user-alerts",
|
||||
// ExpectedStatus: 200,
|
||||
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||
// TestAppFactory: testAppFactory,
|
||||
// },
|
||||
{
|
||||
Name: "POST no auth",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST no body",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST bad data",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"invalidField": "this should cause validation error",
|
||||
"threshold": "not a number",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST malformed JSON",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data multiple systems",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 69,
|
||||
"min": 9,
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
// check total alerts
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
// check alert has correct values
|
||||
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data single system",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id},
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: false, should not overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system1.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: true, should overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system2.Id},
|
||||
"overwrite": true,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user2.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE no auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert multiple systems",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "Memory",
|
||||
"system": systemId,
|
||||
"user": user1.Id,
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
})
|
||||
assert.NoError(t, err, "should create alert")
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "User 2 should not be able to delete alert of user 1",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, user := range []string{user1.Id, user2.Id} {
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
func TestSendTestNotification(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
userToken, err := user.NewAuthToken()
|
||||
|
||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||
assert.NoError(t, err, "Failed to create admin user")
|
||||
adminUserToken, err := adminUser.NewAuthToken()
|
||||
|
||||
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
||||
assert.NoError(t, err, "Failed to create superuser")
|
||||
superuserToken, err := superuser.NewAuthToken()
|
||||
assert.NoError(t, err, "Failed to create superuser auth token")
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
{
|
||||
Name: "POST /test-notification - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - with external auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://8.8.8.8",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"err\":"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - local url with user auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://localhost:8010",
|
||||
}),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Only admins"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - internal url with user auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic+http://192.168.0.5",
|
||||
}),
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Only admins"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - internal url with admin auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": adminUserToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"err\":"},
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - internal url with superuser auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": superuserToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"err\":"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
@@ -109,6 +109,18 @@ func (am *AlertManager) cancelPendingAlert(alertID string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// CancelPendingStatusAlerts cancels all pending status alert timers for a given system.
|
||||
// This is called when a system is paused to prevent delayed alerts from firing.
|
||||
func (am *AlertManager) CancelPendingStatusAlerts(systemID string) {
|
||||
am.pendingAlerts.Range(func(key, value any) bool {
|
||||
info := value.(*alertInfo)
|
||||
if info.alertData.SystemID == systemID {
|
||||
am.cancelPendingAlert(key.(string))
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
|
||||
func (am *AlertManager) processPendingAlert(alertID string) {
|
||||
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
|
||||
|
||||
@@ -941,3 +941,68 @@ func TestStatusAlertClearedBeforeSend(t *testing.T) {
|
||||
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCancelPendingStatusAlertsClearsAllAlertsForSystem(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
|
||||
require.NoError(t, err)
|
||||
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
|
||||
require.NoError(t, hub.Save(userSettings))
|
||||
|
||||
systemCollection, err := hub.FindCollectionByNameOrId("systems")
|
||||
require.NoError(t, err)
|
||||
|
||||
system1 := core.NewRecord(systemCollection)
|
||||
system1.Set("name", "system-1")
|
||||
system1.Set("status", "up")
|
||||
system1.Set("host", "127.0.0.1")
|
||||
system1.Set("users", []string{user.Id})
|
||||
require.NoError(t, hub.Save(system1))
|
||||
|
||||
system2 := core.NewRecord(systemCollection)
|
||||
system2.Set("name", "system-2")
|
||||
system2.Set("status", "up")
|
||||
system2.Set("host", "127.0.0.2")
|
||||
system2.Set("users", []string{user.Id})
|
||||
require.NoError(t, hub.Save(system2))
|
||||
|
||||
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
|
||||
require.NoError(t, err)
|
||||
|
||||
alert1 := core.NewRecord(alertCollection)
|
||||
alert1.Set("user", user.Id)
|
||||
alert1.Set("system", system1.Id)
|
||||
alert1.Set("name", "Status")
|
||||
alert1.Set("triggered", false)
|
||||
alert1.Set("min", 5)
|
||||
require.NoError(t, hub.Save(alert1))
|
||||
|
||||
alert2 := core.NewRecord(alertCollection)
|
||||
alert2.Set("user", user.Id)
|
||||
alert2.Set("system", system2.Id)
|
||||
alert2.Set("name", "Status")
|
||||
alert2.Set("triggered", false)
|
||||
alert2.Set("min", 5)
|
||||
require.NoError(t, hub.Save(alert2))
|
||||
|
||||
am := alerts.NewTestAlertManagerWithoutWorker(hub)
|
||||
initialEmailCount := hub.TestMailer.TotalSend()
|
||||
|
||||
// Both systems go down
|
||||
require.NoError(t, am.HandleStatusAlerts("down", system1))
|
||||
require.NoError(t, am.HandleStatusAlerts("down", system2))
|
||||
assert.Equal(t, 2, am.GetPendingAlertsCount(), "both systems should have pending alerts")
|
||||
|
||||
// System 1 is paused — cancel its pending alerts
|
||||
am.CancelPendingStatusAlerts(system1.Id)
|
||||
assert.Equal(t, 1, am.GetPendingAlertsCount(), "only system2 alert should remain pending after pausing system1")
|
||||
|
||||
// Expire and process remaining alerts — only system2 should fire
|
||||
am.ForceExpirePendingAlerts()
|
||||
processed, err := am.ProcessPendingAlerts()
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, processed, 1, "only the non-paused system's alert should be processed")
|
||||
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "only system2 should send a down notification")
|
||||
}
|
||||
|
||||
@@ -3,11 +3,6 @@
|
||||
package alerts_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
@@ -16,359 +11,9 @@ import (
|
||||
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// marshal to json and return an io.Reader (for use in ApiScenario.Body)
|
||||
func jsonReader(v any) io.Reader {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return bytes.NewReader(data)
|
||||
}
|
||||
|
||||
func TestUserAlertsApi(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
user1, _ := beszelTests.CreateUser(hub, "alertstest@example.com", "password")
|
||||
user1Token, _ := user1.NewAuthToken()
|
||||
|
||||
user2, _ := beszelTests.CreateUser(hub, "alertstest2@example.com", "password")
|
||||
user2Token, _ := user2.NewAuthToken()
|
||||
|
||||
system1, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system1",
|
||||
"users": []string{user1.Id},
|
||||
"host": "127.0.0.1",
|
||||
})
|
||||
|
||||
system2, _ := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "system2",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
"host": "127.0.0.2",
|
||||
})
|
||||
|
||||
userRecords, _ := hub.CountRecords("users")
|
||||
assert.EqualValues(t, 2, userRecords, "all users should be created")
|
||||
|
||||
systemRecords, _ := hub.CountRecords("systems")
|
||||
assert.EqualValues(t, 2, systemRecords, "all systems should be created")
|
||||
|
||||
testAppFactory := func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
}
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
// {
|
||||
// Name: "GET not implemented - returns index",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/beszel/user-alerts",
|
||||
// ExpectedStatus: 200,
|
||||
// ExpectedContent: []string{"<html ", "globalThis.BESZEL"},
|
||||
// TestAppFactory: testAppFactory,
|
||||
// },
|
||||
{
|
||||
Name: "POST no auth",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST no body",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST bad data",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"invalidField": "this should cause validation error",
|
||||
"threshold": "not a number",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST malformed JSON",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Bad data"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: strings.NewReader(`{"alertType": "cpu", "threshold": 80, "enabled": true,}`),
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data multiple systems",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 69,
|
||||
"min": 9,
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
// check total alerts
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
// check alert has correct values
|
||||
matchingAlerts, _ := app.CountRecords("alerts", dbx.HashExp{"name": "CPU", "user": user1.Id, "system": system1.Id, "value": 69, "min": 9})
|
||||
assert.EqualValues(t, 1, matchingAlerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "POST valid alert data single system",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id},
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
}),
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1Alerts, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 3, user1Alerts, "should have 3 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: false, should not overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system1.Id},
|
||||
"overwrite": false,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user1.Id})
|
||||
assert.EqualValues(t, 80, alert.Get("value"), "should have 80 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Overwrite: true, should overwrite existing alert",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"value": 45,
|
||||
"min": 5,
|
||||
"systems": []string{system2.Id},
|
||||
"overwrite": true,
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user2.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
alert, _ := app.FindFirstRecordByFilter("alerts", "name = 'CPU' && user = {:user}", dbx.Params{"user": user2.Id})
|
||||
assert.EqualValues(t, 45, alert.Get("value"), "should have 45 as value")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE no auth",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 1, alerts, "should have 1 alert")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system1.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system1.Id,
|
||||
"user": user1.Id,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DELETE alert multiple systems",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user1Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":2", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "Memory",
|
||||
"systems": []string{system1.Id, system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, systemId := range []string{system1.Id, system2.Id} {
|
||||
_, err := beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "Memory",
|
||||
"system": systemId,
|
||||
"user": user1.Id,
|
||||
"value": 90,
|
||||
"min": 10,
|
||||
})
|
||||
assert.NoError(t, err, "should create alert")
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.Zero(t, alerts, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "User 2 should not be able to delete alert of user 1",
|
||||
Method: http.MethodDelete,
|
||||
URL: "/api/beszel/user-alerts",
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"\"count\":1", "\"success\":true"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"name": "CPU",
|
||||
"systems": []string{system2.Id},
|
||||
}),
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.ClearCollection(t, app, "alerts")
|
||||
for _, user := range []string{user1.Id, user2.Id} {
|
||||
beszelTests.CreateRecord(app, "alerts", map[string]any{
|
||||
"name": "CPU",
|
||||
"system": system2.Id,
|
||||
"user": user,
|
||||
"value": 80,
|
||||
"min": 10,
|
||||
})
|
||||
}
|
||||
alerts, _ := app.CountRecords("alerts")
|
||||
assert.EqualValues(t, 2, alerts, "should have 2 alerts")
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.EqualValues(t, 1, user2AlertCount, "should have 1 alert")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
user1AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user1.Id})
|
||||
assert.EqualValues(t, 1, user1AlertCount, "should have 1 alert")
|
||||
user2AlertCount, _ := app.CountRecords("alerts", dbx.HashExp{"user": user2.Id})
|
||||
assert.Zero(t, user2AlertCount, "should have 0 alerts")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
scenario.Test(t)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertsHistory(t *testing.T) {
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
|
||||
@@ -95,3 +95,7 @@ func (am *AlertManager) RestorePendingStatusAlerts() error {
|
||||
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||
return am.setAlertTriggered(alert, triggered)
|
||||
}
|
||||
|
||||
func IsInternalURL(rawURL string) (bool, error) {
|
||||
return isInternalURL(rawURL)
|
||||
}
|
||||
|
||||
@@ -195,6 +195,6 @@ func main() {
|
||||
}
|
||||
|
||||
if err := a.Start(serverConfig); err != nil {
|
||||
log.Fatal("Failed to start server: ", err)
|
||||
log.Fatal("Failed to start: ", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ const (
|
||||
GetSmartData
|
||||
// Request detailed systemd service info from agent
|
||||
GetSystemdInfo
|
||||
// Sync network probe configuration to agent
|
||||
SyncNetworkProbes
|
||||
// Add new actions here...
|
||||
)
|
||||
|
||||
|
||||
32
internal/entities/probe/probe.go
Normal file
32
internal/entities/probe/probe.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package probe
|
||||
|
||||
import "strconv"
|
||||
|
||||
// Config defines a network probe task sent from hub to agent.
|
||||
type Config struct {
|
||||
Target string `cbor:"0,keyasint" json:"target"`
|
||||
Protocol string `cbor:"1,keyasint" json:"protocol"` // "icmp", "tcp", or "http"
|
||||
Port uint16 `cbor:"2,keyasint,omitempty" json:"port,omitempty"`
|
||||
Interval uint16 `cbor:"3,keyasint" json:"interval"` // seconds
|
||||
}
|
||||
|
||||
// Result holds aggregated probe results for a single target.
|
||||
//
|
||||
// 0: avg latency in ms
|
||||
//
|
||||
// 1: min latency in ms
|
||||
//
|
||||
// 2: max latency in ms
|
||||
//
|
||||
// 3: packet loss percentage (0-100)
|
||||
type Result []float64
|
||||
|
||||
// Key returns the map key used for this probe config (e.g. "icmp:1.1.1.1", "tcp:host:443", "http:https://example.com").
|
||||
func (c Config) Key() string {
|
||||
switch c.Protocol {
|
||||
case "tcp":
|
||||
return c.Protocol + ":" + c.Target + ":" + strconv.FormatUint(uint64(c.Port), 10)
|
||||
default:
|
||||
return c.Protocol + ":" + c.Target
|
||||
}
|
||||
}
|
||||
@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
||||
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
|
||||
// NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
||||
NVMeTotalCapacity uint64 `json:"nvme_total_capacity"`
|
||||
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
|
||||
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
||||
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||
)
|
||||
|
||||
@@ -48,6 +49,8 @@ type Stats struct {
|
||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
||||
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
|
||||
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
|
||||
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
|
||||
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
|
||||
}
|
||||
|
||||
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
||||
@@ -93,10 +96,12 @@ type FsStats struct {
|
||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
||||
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
||||
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
|
||||
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
|
||||
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
|
||||
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
|
||||
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"8,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
|
||||
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
|
||||
}
|
||||
|
||||
type NetIoStats struct {
|
||||
@@ -170,9 +175,10 @@ type Details struct {
|
||||
|
||||
// Final data structure to return to the hub
|
||||
type CombinedData struct {
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
Stats Stats `json:"stats" cbor:"0,keyasint"`
|
||||
Info Info `json:"info" cbor:"1,keyasint"`
|
||||
Containers []*container.Stats `json:"container" cbor:"2,keyasint"`
|
||||
SystemdServices []*systemd.Service `json:"systemd,omitempty" cbor:"3,keyasint,omitempty"`
|
||||
Details *Details `cbor:"4,keyasint,omitempty"`
|
||||
Probes map[string]probe.Result `cbor:"5,keyasint,omitempty"`
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package hub
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"github.com/henrygd/beszel/internal/ghupdate"
|
||||
"github.com/henrygd/beszel/internal/hub/config"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
@@ -25,6 +27,32 @@ type UpdateInfo struct {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
// Middleware to allow only admin role users
|
||||
var requireAdminRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
|
||||
return e.Auth.GetString("role") == "admin"
|
||||
})
|
||||
|
||||
// Middleware to exclude readonly users
|
||||
var excludeReadOnlyRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
|
||||
return e.Auth.GetString("role") != "readonly"
|
||||
})
|
||||
|
||||
// customAuthMiddleware handles boilerplate for custom authentication middlewares. fn should
|
||||
// return true if the request is allowed, false otherwise. e.Auth is guaranteed to be non-nil.
|
||||
func customAuthMiddleware(fn func(*core.RequestEvent) bool) func(*core.RequestEvent) error {
|
||||
return func(e *core.RequestEvent) error {
|
||||
if e.Auth == nil {
|
||||
return e.UnauthorizedError("The request requires valid record authorization token.", nil)
|
||||
}
|
||||
if !fn(e) {
|
||||
return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil)
|
||||
}
|
||||
return e.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// registerMiddlewares registers custom middlewares
|
||||
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||
// authorizes request with user matching the provided email
|
||||
@@ -33,7 +61,7 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||
return e.Next()
|
||||
}
|
||||
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
||||
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
|
||||
e.Auth, err = e.App.FindAuthRecordByEmail("users", email)
|
||||
if err != nil || !isAuthRefresh {
|
||||
return e.Next()
|
||||
}
|
||||
@@ -43,13 +71,13 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||
return e.Next()
|
||||
}
|
||||
// authenticate with trusted header
|
||||
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
||||
if autoLogin, _ := utils.GetEnv("AUTO_LOGIN"); autoLogin != "" {
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
return authorizeRequestWithEmail(e, autoLogin)
|
||||
})
|
||||
}
|
||||
// authenticate with trusted header
|
||||
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||
if trustedHeader, _ := utils.GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
|
||||
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
||||
})
|
||||
@@ -77,30 +105,30 @@ func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
|
||||
apiAuth.GET("/info", h.getInfo)
|
||||
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
|
||||
// check for updates
|
||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
var updateInfo UpdateInfo
|
||||
apiAuth.GET("/update", updateInfo.getUpdate)
|
||||
}
|
||||
// send test notification
|
||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||
// heartbeat status and test
|
||||
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
|
||||
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
|
||||
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
|
||||
apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
|
||||
// get config.yml content
|
||||
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
||||
apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
|
||||
// handle agent websocket connection
|
||||
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||
// get or create universal tokens
|
||||
apiAuth.GET("/universal-token", h.getUniversalToken)
|
||||
apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
|
||||
// update / delete user alerts
|
||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||
// refresh SMART devices for a system
|
||||
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
||||
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
|
||||
// get systemd service details
|
||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||
// /containers routes
|
||||
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||
// get container logs
|
||||
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
||||
// get container info
|
||||
@@ -120,7 +148,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error {
|
||||
Key: h.pubKey,
|
||||
Version: beszel.Version,
|
||||
}
|
||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||
info.CheckUpdate = true
|
||||
}
|
||||
return e.JSON(http.StatusOK, info)
|
||||
@@ -153,6 +181,10 @@ func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
|
||||
|
||||
// GetUniversalToken handles the universal token API endpoint (create, read, delete)
|
||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||
if e.Auth.IsSuperuser() {
|
||||
return e.ForbiddenError("Superusers cannot use universal tokens", nil)
|
||||
}
|
||||
|
||||
tokenMap := universalTokenMap.GetMap()
|
||||
userID := e.Auth.Id
|
||||
query := e.Request.URL.Query()
|
||||
@@ -246,9 +278,6 @@ func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||
|
||||
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
|
||||
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
if h.hb == nil {
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"enabled": false,
|
||||
@@ -266,9 +295,6 @@ func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
||||
|
||||
// testHeartbeat triggers a single heartbeat ping and returns the result
|
||||
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
if h.hb == nil {
|
||||
return e.JSON(http.StatusOK, map[string]any{
|
||||
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
|
||||
@@ -285,21 +311,18 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst
|
||||
systemID := e.Request.URL.Query().Get("system")
|
||||
containerID := e.Request.URL.Query().Get("container")
|
||||
|
||||
if systemID == "" || containerID == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
|
||||
}
|
||||
if !containerIDPattern.MatchString(containerID) {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
|
||||
if systemID == "" || containerID == "" || !containerIDPattern.MatchString(containerID) {
|
||||
return e.BadRequestError("Invalid system or container parameter", nil)
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
|
||||
data, err := fetchFunc(system, containerID)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
|
||||
@@ -325,15 +348,23 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||
serviceName := query.Get("service")
|
||||
|
||||
if systemID == "" || serviceName == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
|
||||
return e.BadRequestError("Invalid system or service parameter", nil)
|
||||
}
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
// verify service exists before fetching details
|
||||
_, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{
|
||||
"system": systemID,
|
||||
"name": serviceName,
|
||||
})
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||
return e.NotFoundError("", err)
|
||||
}
|
||||
details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
||||
@@ -344,17 +375,16 @@ func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
|
||||
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||
systemID := e.Request.URL.Query().Get("system")
|
||||
if systemID == "" {
|
||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
|
||||
return e.BadRequestError("Invalid system parameter", nil)
|
||||
}
|
||||
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil {
|
||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
||||
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||
return e.NotFoundError("", nil)
|
||||
}
|
||||
|
||||
// Fetch and save SMART devices
|
||||
if err := system.FetchAndSaveSmartDevices(); err != nil {
|
||||
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return e.InternalServerError("", err)
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||
|
||||
@@ -3,6 +3,7 @@ package hub_test
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
@@ -25,33 +26,33 @@ func jsonReader(v any) io.Reader {
|
||||
}
|
||||
|
||||
func TestApiRoutesAuthentication(t *testing.T) {
|
||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
||||
hub, user := beszelTests.GetHubWithUser(t)
|
||||
defer hub.Cleanup()
|
||||
|
||||
hub.StartHub()
|
||||
|
||||
// Create test user and get auth token
|
||||
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||
require.NoError(t, err, "Failed to create test user")
|
||||
|
||||
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
|
||||
"email": "admin@example.com",
|
||||
"password": "password123",
|
||||
"role": "admin",
|
||||
})
|
||||
require.NoError(t, err, "Failed to create admin user")
|
||||
adminUserToken, err := adminUser.NewAuthToken()
|
||||
|
||||
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
|
||||
// "email": "superuser@example.com",
|
||||
// "password": "password123",
|
||||
// })
|
||||
// require.NoError(t, err, "Failed to create superuser")
|
||||
|
||||
userToken, err := user.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create auth token")
|
||||
|
||||
// Create test system for user-alerts endpoints
|
||||
// Create test user and get auth token
|
||||
user2, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
|
||||
require.NoError(t, err, "Failed to create test user")
|
||||
user2Token, err := user2.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create user2 auth token")
|
||||
|
||||
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
|
||||
require.NoError(t, err, "Failed to create admin user")
|
||||
adminUserToken, err := adminUser.NewAuthToken()
|
||||
|
||||
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
|
||||
require.NoError(t, err, "Failed to create readonly user")
|
||||
readOnlyUserToken, err := readOnlyUser.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create readonly user auth token")
|
||||
|
||||
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
|
||||
require.NoError(t, err, "Failed to create superuser")
|
||||
superuserToken, err := superuser.NewAuthToken()
|
||||
require.NoError(t, err, "Failed to create superuser auth token")
|
||||
|
||||
// Create test system
|
||||
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"users": []string{user.Id},
|
||||
@@ -65,31 +66,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
|
||||
scenarios := []beszelTests.ApiScenario{
|
||||
// Auth Protected Routes - Should require authentication
|
||||
{
|
||||
Name: "POST /test-notification - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "POST /test-notification - with auth should succeed",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/test-notification",
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
Body: jsonReader(map[string]any{
|
||||
"url": "generic://127.0.0.1",
|
||||
}),
|
||||
ExpectedStatus: 200,
|
||||
ExpectedContent: []string{"sending message"},
|
||||
},
|
||||
{
|
||||
Name: "GET /config-yaml - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
@@ -106,7 +82,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin"},
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -136,7 +112,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin role"},
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -158,7 +134,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Requires admin role"},
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -202,6 +178,74 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /universal-token - superuser should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/universal-token",
|
||||
Headers: map[string]string{
|
||||
"Authorization": superuserToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"Superusers cannot use universal tokens"},
|
||||
TestAppFactory: func(t testing.TB) *pbTests.TestApp {
|
||||
return hub.TestApp
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /universal-token - with readonly auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/universal-token",
|
||||
Headers: map[string]string{
|
||||
"Authorization": readOnlyUserToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /smart/refresh - missing system should fail 400 with user auth",
|
||||
Method: http.MethodPost,
|
||||
URL: "/api/beszel/smart/refresh",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Invalid", "system", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /smart/refresh - with readonly auth should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": readOnlyUserToken,
|
||||
},
|
||||
ExpectedStatus: 403,
|
||||
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /smart/refresh - non-user system should fail",
|
||||
Method: http.MethodPost,
|
||||
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /smart/refresh - good user should pass validation",
|
||||
Method: http.MethodPost,
|
||||
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "POST /user-alerts - no auth should fail",
|
||||
Method: http.MethodPost,
|
||||
@@ -273,20 +317,59 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
{
|
||||
Name: "GET /containers/logs - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?system=test-system&container=test-container",
|
||||
URL: "/api/beszel/containers/logs?system=test-system&container=abababababab",
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - request for valid non-user system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/containers/logs?system=%s&container=abababababab", system.Id),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - request for valid non-user system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - SHARE_ALL_SYSTEMS allows non-member user",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
t.Setenv("SHARE_ALL_SYSTEMS", "true")
|
||||
},
|
||||
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
|
||||
t.Setenv("SHARE_ALL_SYSTEMS", "")
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - with auth but missing system param should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?container=test-container",
|
||||
URL: "/api/beszel/containers/logs?container=abababababab",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"system and container parameters are required"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -297,7 +380,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"system and container parameters are required"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -308,7 +391,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"system not found"},
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -319,7 +402,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -330,7 +413,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
@@ -341,9 +424,114 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"invalid container parameter"},
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/logs - good user should pass validation",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=0123456789ab",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /containers/info - good user should pass validation",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=0123456789ab",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
// /systemd routes
|
||||
{
|
||||
Name: "GET /systemd/info - no auth should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
||||
ExpectedStatus: 401,
|
||||
ExpectedContent: []string{"requires valid"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - request for valid non-user system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
Headers: map[string]string{
|
||||
"Authorization": user2Token,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth but missing system param should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/systemd/info?service=nginx.service",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth but missing service param should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 400,
|
||||
ExpectedContent: []string{"Invalid", "parameter"},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth but invalid system should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/systemd/info?system=invalid-system&service=nginx.service",
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - service not in systemd_services collection should fail",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=notregistered.service", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 404,
|
||||
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
{
|
||||
Name: "GET /systemd/info - with auth and existing service record should pass validation",
|
||||
Method: http.MethodGet,
|
||||
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
|
||||
Headers: map[string]string{
|
||||
"Authorization": userToken,
|
||||
},
|
||||
ExpectedStatus: 500,
|
||||
ExpectedContent: []string{"Something went wrong while processing your request."},
|
||||
TestAppFactory: testAppFactory,
|
||||
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
|
||||
beszelTests.CreateRecord(app, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "nginx.service",
|
||||
"state": 0,
|
||||
"sub": 1,
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
// Auth Optional Routes - Should work without authentication
|
||||
{
|
||||
@@ -434,13 +622,17 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
||||
"systems": []string{system.Id},
|
||||
}),
|
||||
},
|
||||
{
|
||||
Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
|
||||
Method: http.MethodGet,
|
||||
URL: "/api/beszel/update",
|
||||
ExpectedStatus: 502,
|
||||
TestAppFactory: testAppFactory,
|
||||
},
|
||||
// this works but diff behavior on prod vs dev.
|
||||
// dev returns 502; prod returns 200 with static html page 404
|
||||
// TODO: align dev and prod behavior and re-enable this test
|
||||
// {
|
||||
// Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
|
||||
// Method: http.MethodGet,
|
||||
// URL: "/api/beszel/update",
|
||||
// NotExpectedContent: []string{"v:", "\"v\":"},
|
||||
// ExpectedStatus: 502,
|
||||
// TestAppFactory: testAppFactory,
|
||||
// },
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package hub
|
||||
|
||||
import "github.com/pocketbase/pocketbase/core"
|
||||
import (
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
type collectionRules struct {
|
||||
list *string
|
||||
@@ -22,11 +25,11 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
}
|
||||
|
||||
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
||||
disablePasswordAuth, _ := GetEnv("DISABLE_PASSWORD_AUTH")
|
||||
disablePasswordAuth, _ := utils.GetEnv("DISABLE_PASSWORD_AUTH")
|
||||
usersCollection.PasswordAuth.Enabled = disablePasswordAuth != "true"
|
||||
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||
// allow oauth user creation if USER_CREATION is set
|
||||
if userCreation, _ := GetEnv("USER_CREATION"); userCreation == "true" {
|
||||
if userCreation, _ := utils.GetEnv("USER_CREATION"); userCreation == "true" {
|
||||
cr := "@request.context = 'oauth2'"
|
||||
usersCollection.CreateRule = &cr
|
||||
} else {
|
||||
@@ -34,7 +37,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
}
|
||||
|
||||
// enable mfaOtp mfa if MFA_OTP env var is set
|
||||
mfaOtp, _ := GetEnv("MFA_OTP")
|
||||
mfaOtp, _ := utils.GetEnv("MFA_OTP")
|
||||
usersCollection.OTP.Length = 6
|
||||
superusersCollection.OTP.Length = 6
|
||||
usersCollection.OTP.Enabled = mfaOtp == "true"
|
||||
@@ -50,7 +53,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
|
||||
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
|
||||
// system-scoped data. Write rules continue to block readonly users.
|
||||
shareAllSystems, _ := GetEnv("SHARE_ALL_SYSTEMS")
|
||||
shareAllSystems, _ := utils.GetEnv("SHARE_ALL_SYSTEMS")
|
||||
|
||||
authenticatedRule := "@request.auth.id != \"\""
|
||||
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"
|
||||
@@ -75,7 +78,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services"}, collectionRules{
|
||||
if err := applyCollectionRules(app, []string{"containers", "container_stats", "system_stats", "systemd_services", "network_probe_stats"}, collectionRules{
|
||||
list: &systemScopedReadRule,
|
||||
}); err != nil {
|
||||
return err
|
||||
@@ -89,7 +92,7 @@ func setCollectionAuthSettings(app core.App) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := applyCollectionRules(app, []string{"fingerprints"}, collectionRules{
|
||||
if err := applyCollectionRules(app, []string{"fingerprints", "network_probes"}, collectionRules{
|
||||
list: &systemScopedReadRule,
|
||||
view: &systemScopedReadRule,
|
||||
create: &systemScopedWriteRule,
|
||||
|
||||
@@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
|
||||
|
||||
// Returns the current config.yml file as a JSON object
|
||||
func GetYamlConfig(e *core.RequestEvent) error {
|
||||
if e.Auth.GetString("role") != "admin" {
|
||||
return e.ForbiddenError("Requires admin role", nil)
|
||||
}
|
||||
configContent, err := generateYAML(e.App)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/internal/alerts"
|
||||
"github.com/henrygd/beszel/internal/hub/config"
|
||||
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/users"
|
||||
|
||||
@@ -38,8 +38,6 @@ type Hub struct {
|
||||
appURL string
|
||||
}
|
||||
|
||||
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
||||
|
||||
// NewHub creates a new Hub instance with default configuration
|
||||
func NewHub(app core.App) *Hub {
|
||||
hub := &Hub{App: app}
|
||||
@@ -47,7 +45,7 @@ func NewHub(app core.App) *Hub {
|
||||
hub.um = users.NewUserManager(hub)
|
||||
hub.rm = records.NewRecordManager(hub)
|
||||
hub.sm = systems.NewSystemManager(hub)
|
||||
hub.hb = heartbeat.New(app, GetEnv)
|
||||
hub.hb = heartbeat.New(app, utils.GetEnv)
|
||||
if hub.hb != nil {
|
||||
hub.hbStop = make(chan struct{})
|
||||
}
|
||||
@@ -55,15 +53,6 @@ func NewHub(app core.App) *Hub {
|
||||
return hub
|
||||
}
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
// Fallback to the old unprefixed key
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
// onAfterBootstrapAndMigrations ensures the provided function runs after the database is set up and migrations are applied.
|
||||
// This is a workaround for behavior in PocketBase where onBootstrap runs before migrations, forcing use of onServe for this purpose.
|
||||
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
|
||||
@@ -92,6 +81,7 @@ func (h *Hub) StartHub() error {
|
||||
}
|
||||
// register middlewares
|
||||
h.registerMiddlewares(e)
|
||||
// bind events that aren't set up in different
|
||||
// register api routes
|
||||
if err := h.registerApiRoutes(e); err != nil {
|
||||
return err
|
||||
@@ -120,6 +110,8 @@ func (h *Hub) StartHub() error {
|
||||
h.App.OnRecordCreate("users").BindFunc(h.um.InitializeUserRole)
|
||||
h.App.OnRecordCreate("user_settings").BindFunc(h.um.InitializeUserSettings)
|
||||
|
||||
bindNetworkProbesEvents(h)
|
||||
|
||||
pb, ok := h.App.(*pocketbase.PocketBase)
|
||||
if !ok {
|
||||
return errors.New("not a pocketbase app")
|
||||
@@ -134,7 +126,7 @@ func (h *Hub) initialize(app core.App) error {
|
||||
// batch requests (for alerts)
|
||||
settings.Batch.Enabled = true
|
||||
// set URL if APP_URL env is set
|
||||
if appURL, isSet := GetEnv("APP_URL"); isSet {
|
||||
if appURL, isSet := utils.GetEnv("APP_URL"); isSet {
|
||||
h.appURL = appURL
|
||||
settings.Meta.AppURL = appURL
|
||||
}
|
||||
|
||||
54
internal/hub/probes.go
Normal file
54
internal/hub/probes.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/hub/systems"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
func bindNetworkProbesEvents(h *Hub) {
|
||||
// on create, make sure the id is set to a stable hash
|
||||
h.OnRecordCreate("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||
systemID := e.Record.GetString("system")
|
||||
config := &probe.Config{
|
||||
Target: e.Record.GetString("target"),
|
||||
Protocol: e.Record.GetString("protocol"),
|
||||
Port: uint16(e.Record.GetInt("port")),
|
||||
Interval: uint16(e.Record.GetInt("interval")),
|
||||
}
|
||||
key := config.Key()
|
||||
id := systems.MakeStableHashId(systemID, key)
|
||||
e.Record.Set("id", id)
|
||||
return e.Next()
|
||||
})
|
||||
|
||||
// sync probe to agent on creation
|
||||
h.OnRecordAfterCreateSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||
systemID := e.Record.GetString("system")
|
||||
h.syncProbesToAgent(systemID)
|
||||
return e.Next()
|
||||
})
|
||||
// sync probe to agent on delete
|
||||
h.OnRecordAfterDeleteSuccess("network_probes").BindFunc(func(e *core.RecordEvent) error {
|
||||
systemID := e.Record.GetString("system")
|
||||
h.syncProbesToAgent(systemID)
|
||||
return e.Next()
|
||||
})
|
||||
// TODO: if enabled changes, sync to agent
|
||||
}
|
||||
|
||||
// syncProbesToAgent fetches enabled probes for a system and sends them to the agent.
|
||||
func (h *Hub) syncProbesToAgent(systemID string) {
|
||||
system, err := h.sm.GetSystem(systemID)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
configs := h.sm.GetProbeConfigsForSystem(systemID)
|
||||
|
||||
go func() {
|
||||
if err := system.SyncNetworkProbes(configs); err != nil {
|
||||
h.Logger().Warn("failed to sync probes to agent", "system", systemID, "err", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
42
internal/hub/server.go
Normal file
42
internal/hub/server.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package hub
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
)
|
||||
|
||||
// PublicAppInfo defines the structure of the public app information that will be injected into the HTML
|
||||
type PublicAppInfo struct {
|
||||
BASE_PATH string
|
||||
HUB_VERSION string
|
||||
HUB_URL string
|
||||
OAUTH_DISABLE_POPUP bool `json:"OAUTH_DISABLE_POPUP,omitempty"`
|
||||
}
|
||||
|
||||
// modifyIndexHTML injects the public app information into the index.html content
|
||||
func modifyIndexHTML(hub *Hub, html []byte) string {
|
||||
info := getPublicAppInfo(hub)
|
||||
content, err := json.Marshal(info)
|
||||
if err != nil {
|
||||
return string(html)
|
||||
}
|
||||
htmlContent := strings.ReplaceAll(string(html), "./", info.BASE_PATH)
|
||||
return strings.Replace(htmlContent, "\"{info}\"", string(content), 1)
|
||||
}
|
||||
|
||||
func getPublicAppInfo(hub *Hub) PublicAppInfo {
|
||||
parsedURL, _ := url.Parse(hub.appURL)
|
||||
info := PublicAppInfo{
|
||||
BASE_PATH: strings.TrimSuffix(parsedURL.Path, "/") + "/",
|
||||
HUB_VERSION: beszel.Version,
|
||||
HUB_URL: hub.appURL,
|
||||
}
|
||||
if val, _ := utils.GetEnv("OAUTH_DISABLE_POPUP"); val == "true" {
|
||||
info.OAUTH_DISABLE_POPUP = true
|
||||
}
|
||||
return info
|
||||
}
|
||||
@@ -5,14 +5,11 @@ package hub
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||
)
|
||||
@@ -39,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
}
|
||||
resp.Body.Close()
|
||||
// Create a new response with the modified body
|
||||
modifiedBody := rm.modifyHTML(string(body))
|
||||
modifiedBody := modifyIndexHTML(rm.hub, body)
|
||||
resp.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||
resp.ContentLength = int64(len(modifiedBody))
|
||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", len(modifiedBody)))
|
||||
@@ -47,22 +44,8 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (rm *responseModifier) modifyHTML(html string) string {
|
||||
parsedURL, err := url.Parse(rm.hub.appURL)
|
||||
if err != nil {
|
||||
return html
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
html = strings.ReplaceAll(html, "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", rm.hub.appURL, 1)
|
||||
return html
|
||||
}
|
||||
|
||||
// startServer sets up the development server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
slog.Info("starting server", "appURL", h.appURL)
|
||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||
Scheme: "http",
|
||||
Host: "localhost:5173",
|
||||
|
||||
@@ -5,10 +5,9 @@ package hub
|
||||
import (
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/henrygd/beszel/internal/site"
|
||||
|
||||
"github.com/pocketbase/pocketbase/apis"
|
||||
@@ -17,22 +16,13 @@ import (
|
||||
|
||||
// startServer sets up the production server for Beszel
|
||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||
// parse app url
|
||||
parsedURL, err := url.Parse(h.appURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// fix base paths in html if using subpath
|
||||
basePath := strings.TrimSuffix(parsedURL.Path, "/") + "/"
|
||||
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
||||
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
||||
html := modifyIndexHTML(h, indexFile)
|
||||
// set up static asset serving
|
||||
staticPaths := [2]string{"/static/", "/assets/"}
|
||||
serveStatic := apis.Static(site.DistDirFS, false)
|
||||
// get CSP configuration
|
||||
csp, cspExists := GetEnv("CSP")
|
||||
csp, cspExists := utils.GetEnv("CSP")
|
||||
// add route
|
||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||
// serve static assets if path is in staticPaths
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"log/slog"
|
||||
"math/rand"
|
||||
"net"
|
||||
"strings"
|
||||
@@ -14,9 +15,11 @@ import (
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/hub/transport"
|
||||
"github.com/henrygd/beszel/internal/hub/utils"
|
||||
"github.com/henrygd/beszel/internal/hub/ws"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/entities/systemd"
|
||||
@@ -28,6 +31,7 @@ import (
|
||||
"github.com/lxzan/gws"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
@@ -184,7 +188,7 @@ func (sys *System) handlePaused() {
|
||||
|
||||
// createRecords updates the system record and adds system_stats and container_stats records
|
||||
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
||||
systemRecord, err := sys.getRecord()
|
||||
systemRecord, err := sys.getRecord(sys.manager.hub)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -237,6 +241,12 @@ func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error
|
||||
}
|
||||
}
|
||||
|
||||
if data.Probes != nil {
|
||||
if err := updateNetworkProbesRecords(txApp, data.Probes, sys.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// update system record (do this last because it triggers alerts and we need above records to be inserted first)
|
||||
systemRecord.Set("status", up)
|
||||
systemRecord.Set("info", data.Info)
|
||||
@@ -288,7 +298,7 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
||||
for i, service := range data {
|
||||
suffix := fmt.Sprintf("%d", i)
|
||||
valueStrings = append(valueStrings, fmt.Sprintf("({:id%[1]s}, {:system}, {:name%[1]s}, {:state%[1]s}, {:sub%[1]s}, {:cpu%[1]s}, {:cpuPeak%[1]s}, {:memory%[1]s}, {:memPeak%[1]s}, {:updated})", suffix))
|
||||
params["id"+suffix] = makeStableHashId(systemId, service.Name)
|
||||
params["id"+suffix] = MakeStableHashId(systemId, service.Name)
|
||||
params["name"+suffix] = service.Name
|
||||
params["state"+suffix] = service.State
|
||||
params["sub"+suffix] = service.Sub
|
||||
@@ -305,6 +315,84 @@ func createSystemdStatsRecords(app core.App, data []*systemd.Service, systemId s
|
||||
return err
|
||||
}
|
||||
|
||||
func updateNetworkProbesRecords(app core.App, data map[string]probe.Result, systemId string) error {
|
||||
if len(data) == 0 {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
collectionName := "network_probes"
|
||||
|
||||
// If realtime updates are active, we save via PocketBase records to trigger realtime events.
|
||||
// Otherwise we can do a more efficient direct update via SQL
|
||||
realtimeActive := utils.RealtimeActiveForCollection(app, collectionName, func(filterQuery string) bool {
|
||||
slog.Info("Checking realtime subscription filter for network probes", "filterQuery", filterQuery)
|
||||
return !strings.Contains(filterQuery, "system") || strings.Contains(filterQuery, systemId)
|
||||
})
|
||||
|
||||
var db dbx.Builder
|
||||
var nowString string
|
||||
var updateQuery *dbx.Query
|
||||
if !realtimeActive {
|
||||
db = app.DB()
|
||||
nowString = time.Now().UTC().Format(types.DefaultDateLayout)
|
||||
sql := fmt.Sprintf("UPDATE %s SET latency={:latency}, loss={:loss}, updated={:updated} WHERE id={:id}", collectionName)
|
||||
updateQuery = db.NewQuery(sql)
|
||||
}
|
||||
|
||||
// insert network probe stats records
|
||||
switch realtimeActive {
|
||||
case true:
|
||||
collection, _ := app.FindCachedCollectionByNameOrId("network_probe_stats")
|
||||
record := core.NewRecord(collection)
|
||||
record.Set("system", systemId)
|
||||
record.Set("stats", data)
|
||||
record.Set("type", "1m")
|
||||
err = app.SaveNoValidate(record)
|
||||
default:
|
||||
if dataJson, e := json.Marshal(data); e == nil {
|
||||
sql := "INSERT INTO network_probe_stats (system, stats, type, created) VALUES ({:system}, {:stats}, {:type}, {:created})"
|
||||
insertQuery := db.NewQuery(sql)
|
||||
_, err = insertQuery.Bind(dbx.Params{
|
||||
"system": systemId,
|
||||
"stats": dataJson,
|
||||
"type": "1m",
|
||||
"created": nowString,
|
||||
}).Execute()
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
app.Logger().Error("Failed to update probe stats", "system", systemId, "err", err)
|
||||
}
|
||||
|
||||
// update network_probes records
|
||||
for key := range data {
|
||||
probe := data[key]
|
||||
id := MakeStableHashId(systemId, key)
|
||||
switch realtimeActive {
|
||||
case true:
|
||||
var record *core.Record
|
||||
record, err = app.FindRecordById(collectionName, id)
|
||||
if err == nil {
|
||||
record.Set("latency", probe[0])
|
||||
record.Set("loss", probe[3])
|
||||
err = app.SaveNoValidate(record)
|
||||
}
|
||||
default:
|
||||
_, err = updateQuery.Bind(dbx.Params{
|
||||
"id": id,
|
||||
"latency": probe[0],
|
||||
"loss": probe[3],
|
||||
"updated": nowString,
|
||||
}).Execute()
|
||||
}
|
||||
if err != nil {
|
||||
app.Logger().Warn("Failed to update probe", "system", systemId, "probe", key, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createContainerRecords creates container records
|
||||
func createContainerRecords(app core.App, data []*container.Stats, systemId string) error {
|
||||
if len(data) == 0 {
|
||||
@@ -343,8 +431,8 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
|
||||
|
||||
// getRecord retrieves the system record from the database.
|
||||
// If the record is not found, it removes the system from the manager.
|
||||
func (sys *System) getRecord() (*core.Record, error) {
|
||||
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
|
||||
func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
||||
record, err := app.FindRecordById("systems", sys.Id)
|
||||
if err != nil || record == nil {
|
||||
_ = sys.manager.RemoveSystem(sys.Id)
|
||||
return nil, err
|
||||
@@ -352,6 +440,27 @@ func (sys *System) getRecord() (*core.Record, error) {
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// HasUser checks if the given user is in the system's users list.
|
||||
// Returns true if SHARE_ALL_SYSTEMS is enabled (any authenticated user can access any system).
|
||||
func (sys *System) HasUser(app core.App, user *core.Record) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
if v, _ := utils.GetEnv("SHARE_ALL_SYSTEMS"); v == "true" {
|
||||
return true
|
||||
}
|
||||
var recordData = struct {
|
||||
Users string
|
||||
}{}
|
||||
err := app.DB().NewQuery("SELECT users FROM systems WHERE id={:id}").
|
||||
Bind(dbx.Params{"id": sys.Id}).
|
||||
One(&recordData)
|
||||
if err != nil || recordData.Users == "" {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(recordData.Users, user.Id)
|
||||
}
|
||||
|
||||
// setDown marks a system as down in the database.
|
||||
// It takes the original error that caused the system to go down and returns any error
|
||||
// encountered during the process of updating the system status.
|
||||
@@ -359,7 +468,7 @@ func (sys *System) setDown(originalError error) error {
|
||||
if sys.Status == down || sys.Status == paused {
|
||||
return nil
|
||||
}
|
||||
record, err := sys.getRecord()
|
||||
record, err := sys.getRecord(sys.manager.hub)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -518,7 +627,7 @@ func (sys *System) FetchSmartDataFromAgent() (map[string]smart.SmartData, error)
|
||||
return result, err
|
||||
}
|
||||
|
||||
func makeStableHashId(strings ...string) string {
|
||||
func MakeStableHashId(strings ...string) string {
|
||||
hash := fnv.New32a()
|
||||
for _, str := range strings {
|
||||
hash.Write([]byte(str))
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/henrygd/beszel/internal/hub/ws"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/henrygd/beszel"
|
||||
|
||||
"github.com/blang/semver"
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/store"
|
||||
"golang.org/x/crypto/ssh"
|
||||
@@ -54,6 +56,7 @@ type hubLike interface {
|
||||
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||
CancelPendingStatusAlerts(systemID string)
|
||||
}
|
||||
|
||||
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||
@@ -189,6 +192,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
||||
system.closeSSHConnection()
|
||||
}
|
||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
||||
return e.Next()
|
||||
case pending:
|
||||
// Resume monitoring, preferring existing WebSocket connection
|
||||
@@ -315,6 +319,17 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
||||
if err := sm.AddRecord(systemRecord, system); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Sync network probes to the newly connected agent
|
||||
go func() {
|
||||
configs := sm.GetProbeConfigsForSystem(systemId)
|
||||
if len(configs) > 0 {
|
||||
if err := system.SyncNetworkProbes(configs); err != nil {
|
||||
sm.hub.Logger().Warn("failed to sync probes on connect", "system", systemId, "err", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -327,6 +342,31 @@ func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetProbeConfigsForSystem returns all enabled probe configs for a system.
|
||||
func (sm *SystemManager) GetProbeConfigsForSystem(systemID string) []probe.Config {
|
||||
records, err := sm.hub.FindRecordsByFilter(
|
||||
"network_probes",
|
||||
"system = {:system} && enabled = true",
|
||||
"",
|
||||
0, 0,
|
||||
dbx.Params{"system": systemID},
|
||||
)
|
||||
if err != nil || len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
configs := make([]probe.Config, 0, len(records))
|
||||
for _, r := range records {
|
||||
configs = append(configs, probe.Config{
|
||||
Target: r.GetString("target"),
|
||||
Protocol: r.GetString("protocol"),
|
||||
Port: uint16(r.GetInt("port")),
|
||||
Interval: uint16(r.GetInt("interval")),
|
||||
})
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||
func (sm *SystemManager) createSSHClientConfig() error {
|
||||
privateKey, err := sm.hub.GetSSHKey("")
|
||||
|
||||
57
internal/hub/systems/system_probes.go
Normal file
57
internal/hub/systems/system_probes.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package systems
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/common"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
)
|
||||
|
||||
// SyncNetworkProbes sends probe configurations to the agent.
|
||||
func (sys *System) SyncNetworkProbes(configs []probe.Config) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var result string
|
||||
return sys.request(ctx, common.SyncNetworkProbes, configs, &result)
|
||||
}
|
||||
|
||||
// FetchNetworkProbeResults fetches probe results from the agent.
|
||||
// func (sys *System) FetchNetworkProbeResults() (map[string]probe.Result, error) {
|
||||
// ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
// defer cancel()
|
||||
// var results map[string]probe.Result
|
||||
// err := sys.request(ctx, common.GetNetworkProbeResults, nil, &results)
|
||||
// return results, err
|
||||
// }
|
||||
|
||||
// hasEnabledProbes returns true if this system has any enabled network probes.
|
||||
// func (sys *System) hasEnabledProbes() bool {
|
||||
// count, err := sys.manager.hub.CountRecords("network_probes",
|
||||
// dbx.NewExp("system = {:system} AND enabled = true", dbx.Params{"system": sys.Id}))
|
||||
// return err == nil && count > 0
|
||||
// }
|
||||
|
||||
// fetchAndSaveProbeResults fetches probe results and saves them to the database.
|
||||
// func (sys *System) fetchAndSaveProbeResults() {
|
||||
// hub := sys.manager.hub
|
||||
|
||||
// results, err := sys.FetchNetworkProbeResults()
|
||||
// if err != nil || len(results) == 0 {
|
||||
// return
|
||||
// }
|
||||
|
||||
// collection, err := hub.FindCachedCollectionByNameOrId("network_probe_stats")
|
||||
// if err != nil {
|
||||
// return
|
||||
// }
|
||||
|
||||
// record := core.NewRecord(collection)
|
||||
// record.Set("system", sys.Id)
|
||||
// record.Set("stats", results)
|
||||
// record.Set("type", "1m")
|
||||
|
||||
// if err := hub.SaveNoValidate(record); err != nil {
|
||||
// hub.Logger().Warn("failed to save probe stats", "system", sys.Id, "err", err)
|
||||
// }
|
||||
// }
|
||||
@@ -84,7 +84,7 @@ func (sys *System) saveSmartDevices(smartData map[string]smart.SmartData) error
|
||||
|
||||
func (sys *System) upsertSmartDeviceRecord(collection *core.Collection, deviceKey string, device smart.SmartData) error {
|
||||
hub := sys.manager.hub
|
||||
recordID := makeStableHashId(sys.Id, deviceKey)
|
||||
recordID := MakeStableHashId(sys.Id, deviceKey)
|
||||
|
||||
record, err := hub.FindRecordById(collection, recordID)
|
||||
if err != nil {
|
||||
|
||||
@@ -14,9 +14,9 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
serviceName := "nginx.service"
|
||||
|
||||
// Call multiple times and ensure same result
|
||||
id1 := makeStableHashId(systemId, serviceName)
|
||||
id2 := makeStableHashId(systemId, serviceName)
|
||||
id3 := makeStableHashId(systemId, serviceName)
|
||||
id1 := MakeStableHashId(systemId, serviceName)
|
||||
id2 := MakeStableHashId(systemId, serviceName)
|
||||
id3 := MakeStableHashId(systemId, serviceName)
|
||||
|
||||
assert.Equal(t, id1, id2)
|
||||
assert.Equal(t, id2, id3)
|
||||
@@ -29,10 +29,10 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
serviceName1 := "nginx.service"
|
||||
serviceName2 := "apache.service"
|
||||
|
||||
id1 := makeStableHashId(systemId1, serviceName1)
|
||||
id2 := makeStableHashId(systemId2, serviceName1)
|
||||
id3 := makeStableHashId(systemId1, serviceName2)
|
||||
id4 := makeStableHashId(systemId2, serviceName2)
|
||||
id1 := MakeStableHashId(systemId1, serviceName1)
|
||||
id2 := MakeStableHashId(systemId2, serviceName1)
|
||||
id3 := MakeStableHashId(systemId1, serviceName2)
|
||||
id4 := MakeStableHashId(systemId2, serviceName2)
|
||||
|
||||
// All IDs should be different
|
||||
assert.NotEqual(t, id1, id2)
|
||||
@@ -56,14 +56,14 @@ func TestGetSystemdServiceId(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
id := makeStableHashId(tc.systemId, tc.serviceName)
|
||||
id := MakeStableHashId(tc.systemId, tc.serviceName)
|
||||
// FNV-32 produces 8 hex characters
|
||||
assert.Len(t, id, 8, "ID should be 8 characters for systemId='%s', serviceName='%s'", tc.systemId, tc.serviceName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hexadecimal output", func(t *testing.T) {
|
||||
id := makeStableHashId("test-system", "test-service")
|
||||
id := MakeStableHashId("test-system", "test-service")
|
||||
assert.NotEmpty(t, id)
|
||||
|
||||
// Should only contain hexadecimal characters
|
||||
|
||||
@@ -421,3 +421,60 @@ func testOld(t *testing.T, hub *tests.TestHub) {
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestHasUser(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
sm := hub.GetSystemManager()
|
||||
err = sm.Initialize()
|
||||
require.NoError(t, err)
|
||||
|
||||
user1, err := tests.CreateUser(hub, "user1@test.com", "password123")
|
||||
require.NoError(t, err)
|
||||
user2, err := tests.CreateUser(hub, "user2@test.com", "password123")
|
||||
require.NoError(t, err)
|
||||
|
||||
systemRecord, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "has-user-test",
|
||||
"host": "127.0.0.1",
|
||||
"port": "33914",
|
||||
"users": []string{user1.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
sys, err := sm.GetSystemFromStore(systemRecord.Id)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("user in list returns true", func(t *testing.T) {
|
||||
assert.True(t, sys.HasUser(hub, user1))
|
||||
})
|
||||
|
||||
t.Run("user not in list returns false", func(t *testing.T) {
|
||||
assert.False(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
|
||||
t.Run("unknown user ID returns false", func(t *testing.T) {
|
||||
assert.False(t, sys.HasUser(hub, nil))
|
||||
})
|
||||
|
||||
t.Run("SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
|
||||
t.Setenv("SHARE_ALL_SYSTEMS", "true")
|
||||
assert.True(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
|
||||
t.Run("BESZEL_HUB_SHARE_ALL_SYSTEMS=true grants access to non-member", func(t *testing.T) {
|
||||
t.Setenv("BESZEL_HUB_SHARE_ALL_SYSTEMS", "true")
|
||||
assert.True(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
|
||||
t.Run("additional user works", func(t *testing.T) {
|
||||
assert.False(t, sys.HasUser(hub, user2))
|
||||
systemRecord.Set("users", []string{user1.Id, user2.Id})
|
||||
err = hub.Save(systemRecord)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, sys.HasUser(hub, user1))
|
||||
assert.True(t, sys.HasUser(hub, user2))
|
||||
})
|
||||
}
|
||||
|
||||
39
internal/hub/utils/utils.go
Normal file
39
internal/hub/utils/utils.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Package utils provides utility functions for the hub.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// GetEnv retrieves an environment variable with a "BESZEL_HUB_" prefix, or falls back to the unprefixed key.
|
||||
func GetEnv(key string) (value string, exists bool) {
|
||||
if value, exists = os.LookupEnv("BESZEL_HUB_" + key); exists {
|
||||
return value, exists
|
||||
}
|
||||
return os.LookupEnv(key)
|
||||
}
|
||||
|
||||
// realtimeActiveForCollection checks if there are active WebSocket subscriptions for the given collection.
|
||||
func RealtimeActiveForCollection(app core.App, collectionName string, validateFn func(filterQuery string) bool) bool {
|
||||
broker := app.SubscriptionsBroker()
|
||||
if broker.TotalClients() == 0 {
|
||||
return false
|
||||
}
|
||||
for _, client := range broker.Clients() {
|
||||
subs := client.Subscriptions(collectionName)
|
||||
if len(subs) > 0 {
|
||||
if validateFn == nil {
|
||||
return true
|
||||
}
|
||||
for k := range subs {
|
||||
filter := subs[k].Query["filter"]
|
||||
if validateFn(filter) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1699,6 +1699,223 @@ func init() {
|
||||
"type": "base",
|
||||
"updateRule": null,
|
||||
"viewRule": null
|
||||
},
|
||||
{
|
||||
"id": "np_probes_001",
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "network_probes",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "np_system",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_name",
|
||||
"max": 200,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_target",
|
||||
"max": 500,
|
||||
"min": 1,
|
||||
"name": "target",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_protocol",
|
||||
"maxSelect": 1,
|
||||
"name": "protocol",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": ["icmp", "tcp", "http"]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_port",
|
||||
"max": 65535,
|
||||
"min": 0,
|
||||
"name": "port",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_interval",
|
||||
"max": 3600,
|
||||
"min": 1,
|
||||
"name": "interval",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_enabled",
|
||||
"name": "enabled",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `enabled` + "`" + `\n)"
|
||||
],
|
||||
"system": false
|
||||
},
|
||||
{
|
||||
"id": "np_stats_001",
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "network_probe_stats",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "nps_system",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "nps_stats",
|
||||
"maxSize": 2000000,
|
||||
"name": "stats",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "nps_type",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": ["1m", "10m", "20m", "120m", "480m"]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
||||
],
|
||||
"system": false
|
||||
}
|
||||
]`
|
||||
|
||||
|
||||
62
internal/migrations/1776632983_updated_network_probes.go
Normal file
62
internal/migrations/1776632983_updated_network_probes.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("np_probes_001")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(7, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number926446584",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "latency",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// add field
|
||||
if err := collection.Fields.AddMarshaledJSONAt(8, []byte(`{
|
||||
"hidden": false,
|
||||
"id": "number3726709001",
|
||||
"max": null,
|
||||
"min": null,
|
||||
"name": "loss",
|
||||
"onlyInt": false,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
}`)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return app.Save(collection)
|
||||
}, func(app core.App) error {
|
||||
collection, err := app.FindCollectionByNameOrId("np_probes_001")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// remove field
|
||||
collection.Fields.RemoveById("number926446584")
|
||||
|
||||
// remove field
|
||||
collection.Fields.RemoveById("number3726709001")
|
||||
|
||||
return app.Save(collection)
|
||||
})
|
||||
}
|
||||
245
internal/migrations/1_add_network_probes.go
Normal file
245
internal/migrations/1_add_network_probes.go
Normal file
@@ -0,0 +1,245 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
m "github.com/pocketbase/pocketbase/migrations"
|
||||
)
|
||||
|
||||
func init() {
|
||||
m.Register(func(app core.App) error {
|
||||
jsonData := `[
|
||||
{
|
||||
"id": "np_probes_001",
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "network_probes",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "np_system",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_name",
|
||||
"max": 200,
|
||||
"min": 0,
|
||||
"name": "name",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_target",
|
||||
"max": 500,
|
||||
"min": 1,
|
||||
"name": "target",
|
||||
"pattern": "",
|
||||
"presentable": false,
|
||||
"primaryKey": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_protocol",
|
||||
"maxSelect": 1,
|
||||
"name": "protocol",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": ["icmp", "tcp", "http"]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_port",
|
||||
"max": 65535,
|
||||
"min": 0,
|
||||
"name": "port",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_interval",
|
||||
"max": 3600,
|
||||
"min": 1,
|
||||
"name": "interval",
|
||||
"onlyInt": true,
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "np_enabled",
|
||||
"name": "enabled",
|
||||
"presentable": false,
|
||||
"required": false,
|
||||
"system": false,
|
||||
"type": "bool"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_np_system_enabled` + "`" + ` ON ` + "`" + `network_probes` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `enabled` + "`" + `\n)"
|
||||
],
|
||||
"system": false
|
||||
},
|
||||
{
|
||||
"id": "np_stats_001",
|
||||
"listRule": null,
|
||||
"viewRule": null,
|
||||
"createRule": null,
|
||||
"updateRule": null,
|
||||
"deleteRule": null,
|
||||
"name": "network_probe_stats",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{
|
||||
"autogeneratePattern": "[a-z0-9]{15}",
|
||||
"hidden": false,
|
||||
"id": "text3208210256",
|
||||
"max": 15,
|
||||
"min": 15,
|
||||
"name": "id",
|
||||
"pattern": "^[a-z0-9]+$",
|
||||
"presentable": false,
|
||||
"primaryKey": true,
|
||||
"required": true,
|
||||
"system": true,
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"cascadeDelete": true,
|
||||
"collectionId": "2hz5ncl8tizk5nx",
|
||||
"hidden": false,
|
||||
"id": "nps_system",
|
||||
"maxSelect": 1,
|
||||
"minSelect": 0,
|
||||
"name": "system",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "relation"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "nps_stats",
|
||||
"maxSize": 2000000,
|
||||
"name": "stats",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "json"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "nps_type",
|
||||
"maxSelect": 1,
|
||||
"name": "type",
|
||||
"presentable": false,
|
||||
"required": true,
|
||||
"system": false,
|
||||
"type": "select",
|
||||
"values": ["1m", "10m", "20m", "120m", "480m"]
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate2990389176",
|
||||
"name": "created",
|
||||
"onCreate": true,
|
||||
"onUpdate": false,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
},
|
||||
{
|
||||
"hidden": false,
|
||||
"id": "autodate3332085495",
|
||||
"name": "updated",
|
||||
"onCreate": true,
|
||||
"onUpdate": true,
|
||||
"presentable": false,
|
||||
"system": false,
|
||||
"type": "autodate"
|
||||
}
|
||||
],
|
||||
"indexes": [
|
||||
"CREATE INDEX ` + "`" + `idx_nps_system_type_created` + "`" + ` ON ` + "`" + `network_probe_stats` + "`" + ` (\n ` + "`" + `system` + "`" + `,\n ` + "`" + `type` + "`" + `,\n ` + "`" + `created` + "`" + `\n)"
|
||||
],
|
||||
"system": false
|
||||
}
|
||||
]`
|
||||
|
||||
return app.ImportCollectionsByMarshaledJSON([]byte(jsonData), false)
|
||||
}, func(app core.App) error {
|
||||
// down: remove the network probe collections
|
||||
if c, err := app.FindCollectionByNameOrId("network_probes"); err == nil {
|
||||
if err := app.Delete(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if c, err := app.FindCollectionByNameOrId("network_probe_stats"); err == nil {
|
||||
if err := app.Delete(c); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -3,13 +3,12 @@ package records
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/probe"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
@@ -39,16 +38,6 @@ type StatsRecord struct {
|
||||
Stats []byte `db:"stats"`
|
||||
}
|
||||
|
||||
// global variables for reusing allocations
|
||||
var (
|
||||
statsRecord StatsRecord
|
||||
containerStats []container.Stats
|
||||
sumStats system.Stats
|
||||
tempStats system.Stats
|
||||
queryParams = make(dbx.Params, 1)
|
||||
containerSums = make(map[string]*container.Stats)
|
||||
)
|
||||
|
||||
// Create longer records by averaging shorter records
|
||||
func (rm *RecordManager) CreateLongerRecords() {
|
||||
// start := time.Now()
|
||||
@@ -82,7 +71,7 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
// wrap the operations in a transaction
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
var err error
|
||||
collections := [2]*core.Collection{}
|
||||
collections := [3]*core.Collection{}
|
||||
collections[0], err = txApp.FindCachedCollectionByNameOrId("system_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -91,6 +80,10 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
collections[2], err = txApp.FindCachedCollectionByNameOrId("network_probe_stats")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var systems RecordIds
|
||||
db := txApp.DB()
|
||||
|
||||
@@ -150,8 +143,9 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
case "system_stats":
|
||||
longerRecord.Set("stats", rm.AverageSystemStats(db, recordIds))
|
||||
case "container_stats":
|
||||
|
||||
longerRecord.Set("stats", rm.AverageContainerStats(db, recordIds))
|
||||
case "network_probe_stats":
|
||||
longerRecord.Set("stats", rm.AverageProbeStats(db, recordIds))
|
||||
}
|
||||
if err := txApp.SaveNoValidate(longerRecord); err != nil {
|
||||
log.Println("failed to save longer record", "err", err)
|
||||
@@ -163,41 +157,47 @@ func (rm *RecordManager) CreateLongerRecords() {
|
||||
return nil
|
||||
})
|
||||
|
||||
statsRecord.Stats = statsRecord.Stats[:0]
|
||||
|
||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
||||
}
|
||||
|
||||
// Calculate the average stats of a list of system_stats records without reflect
|
||||
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
|
||||
// Clear/reset global structs for reuse
|
||||
sumStats = system.Stats{}
|
||||
tempStats = system.Stats{}
|
||||
sum := &sumStats
|
||||
stats := &tempStats
|
||||
stats := make([]system.Stats, 0, len(records))
|
||||
var row StatsRecord
|
||||
params := make(dbx.Params, 1)
|
||||
for _, rec := range records {
|
||||
row.Stats = row.Stats[:0]
|
||||
params["id"] = rec.Id
|
||||
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(params).One(&row)
|
||||
var s system.Stats
|
||||
if err := json.Unmarshal(row.Stats, &s); err != nil {
|
||||
continue
|
||||
}
|
||||
stats = append(stats, s)
|
||||
}
|
||||
result := AverageSystemStatsSlice(stats)
|
||||
return &result
|
||||
}
|
||||
|
||||
// AverageSystemStatsSlice computes the average of a slice of system stats.
|
||||
func AverageSystemStatsSlice(records []system.Stats) system.Stats {
|
||||
var sum system.Stats
|
||||
count := float64(len(records))
|
||||
if count == 0 {
|
||||
return sum
|
||||
}
|
||||
|
||||
// necessary because uint8 is not big enough for the sum
|
||||
batterySum := 0
|
||||
// accumulate per-core usage across records
|
||||
var cpuCoresSums []uint64
|
||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||
var cpuBreakdownSums []float64
|
||||
|
||||
count := float64(len(records))
|
||||
tempCount := float64(0)
|
||||
|
||||
// Accumulate totals
|
||||
for _, record := range records {
|
||||
id := record.Id
|
||||
// clear global statsRecord for reuse
|
||||
statsRecord.Stats = statsRecord.Stats[:0]
|
||||
// reset tempStats each iteration to avoid omitzero fields retaining stale values
|
||||
*stats = system.Stats{}
|
||||
|
||||
queryParams["id"] = id
|
||||
db.NewQuery("SELECT stats FROM system_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||
if err := json.Unmarshal(statsRecord.Stats, stats); err != nil {
|
||||
continue
|
||||
}
|
||||
for i := range records {
|
||||
stats := &records[i]
|
||||
|
||||
sum.Cpu += stats.Cpu
|
||||
// accumulate cpu time breakdowns if present
|
||||
@@ -205,8 +205,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
||||
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
||||
}
|
||||
for i, v := range stats.CpuBreakdown {
|
||||
cpuBreakdownSums[i] += v
|
||||
for j, v := range stats.CpuBreakdown {
|
||||
cpuBreakdownSums[j] += v
|
||||
}
|
||||
}
|
||||
sum.Mem += stats.Mem
|
||||
@@ -230,6 +230,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||
sum.DiskIO[0] += stats.DiskIO[0]
|
||||
sum.DiskIO[1] += stats.DiskIO[1]
|
||||
for i := range stats.DiskIoStats {
|
||||
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
||||
}
|
||||
batterySum += int(stats.Battery[0])
|
||||
sum.Battery[1] = stats.Battery[1]
|
||||
|
||||
@@ -239,8 +242,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
// extend slices to accommodate core count
|
||||
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
||||
}
|
||||
for i, v := range stats.CpuCoresUsage {
|
||||
cpuCoresSums[i] += uint64(v)
|
||||
for j, v := range stats.CpuCoresUsage {
|
||||
cpuCoresSums[j] += uint64(v)
|
||||
}
|
||||
}
|
||||
// Set peak values
|
||||
@@ -254,6 +257,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
||||
sum.MaxDiskIO[0] = max(sum.MaxDiskIO[0], stats.MaxDiskIO[0], stats.DiskIO[0])
|
||||
sum.MaxDiskIO[1] = max(sum.MaxDiskIO[1], stats.MaxDiskIO[1], stats.DiskIO[1])
|
||||
for i := range stats.DiskIoStats {
|
||||
sum.MaxDiskIoStats[i] = max(sum.MaxDiskIoStats[i], stats.MaxDiskIoStats[i], stats.DiskIoStats[i])
|
||||
}
|
||||
|
||||
// Accumulate network interfaces
|
||||
if sum.NetworkInterfaces == nil {
|
||||
@@ -299,6 +305,10 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
fs.DiskWriteBytes += value.DiskWriteBytes
|
||||
fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes)
|
||||
fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes)
|
||||
for i := range value.DiskIoStats {
|
||||
fs.DiskIoStats[i] += value.DiskIoStats[i]
|
||||
fs.MaxDiskIoStats[i] = max(fs.MaxDiskIoStats[i], value.MaxDiskIoStats[i], value.DiskIoStats[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,103 +343,107 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
}
|
||||
}
|
||||
|
||||
// Compute averages in place
|
||||
if count > 0 {
|
||||
sum.Cpu = twoDecimals(sum.Cpu / count)
|
||||
sum.Mem = twoDecimals(sum.Mem / count)
|
||||
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
||||
sum.MemPct = twoDecimals(sum.MemPct / count)
|
||||
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
||||
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
||||
sum.Swap = twoDecimals(sum.Swap / count)
|
||||
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
||||
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
||||
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
||||
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||
sum.Battery[0] = uint8(batterySum / int(count))
|
||||
// Compute averages
|
||||
sum.Cpu = twoDecimals(sum.Cpu / count)
|
||||
sum.Mem = twoDecimals(sum.Mem / count)
|
||||
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
||||
sum.MemPct = twoDecimals(sum.MemPct / count)
|
||||
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
||||
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
||||
sum.Swap = twoDecimals(sum.Swap / count)
|
||||
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
||||
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
||||
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
||||
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
||||
for i := range sum.DiskIoStats {
|
||||
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
|
||||
}
|
||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
||||
sum.Battery[0] = uint8(batterySum / int(count))
|
||||
|
||||
// Average network interfaces
|
||||
if sum.NetworkInterfaces != nil {
|
||||
for key := range sum.NetworkInterfaces {
|
||||
sum.NetworkInterfaces[key] = [4]uint64{
|
||||
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||
sum.NetworkInterfaces[key][2],
|
||||
sum.NetworkInterfaces[key][3],
|
||||
// Average network interfaces
|
||||
if sum.NetworkInterfaces != nil {
|
||||
for key := range sum.NetworkInterfaces {
|
||||
sum.NetworkInterfaces[key] = [4]uint64{
|
||||
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||
sum.NetworkInterfaces[key][2],
|
||||
sum.NetworkInterfaces[key][3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average temperatures
|
||||
if sum.Temperatures != nil && tempCount > 0 {
|
||||
for key := range sum.Temperatures {
|
||||
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
|
||||
}
|
||||
}
|
||||
|
||||
// Average extra filesystem stats
|
||||
if sum.ExtraFs != nil {
|
||||
for key := range sum.ExtraFs {
|
||||
fs := sum.ExtraFs[key]
|
||||
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
|
||||
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
||||
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
||||
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
||||
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
||||
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
||||
for i := range fs.DiskIoStats {
|
||||
fs.DiskIoStats[i] = twoDecimals(fs.DiskIoStats[i] / count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Average GPU data
|
||||
if sum.GPUData != nil {
|
||||
for id := range sum.GPUData {
|
||||
gpu := sum.GPUData[id]
|
||||
gpu.Temperature = twoDecimals(gpu.Temperature / count)
|
||||
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
|
||||
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
|
||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||
gpu.Power = twoDecimals(gpu.Power / count)
|
||||
gpu.Count = twoDecimals(gpu.Count / count)
|
||||
|
||||
if gpu.Engines != nil {
|
||||
for engineKey := range gpu.Engines {
|
||||
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
||||
}
|
||||
}
|
||||
|
||||
sum.GPUData[id] = gpu
|
||||
}
|
||||
}
|
||||
|
||||
// Average temperatures
|
||||
if sum.Temperatures != nil && tempCount > 0 {
|
||||
for key := range sum.Temperatures {
|
||||
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
|
||||
}
|
||||
// Average per-core usage
|
||||
if len(cpuCoresSums) > 0 {
|
||||
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
||||
for i := range cpuCoresSums {
|
||||
v := math.Round(float64(cpuCoresSums[i]) / count)
|
||||
avg[i] = uint8(v)
|
||||
}
|
||||
sum.CpuCoresUsage = avg
|
||||
}
|
||||
|
||||
// Average extra filesystem stats
|
||||
if sum.ExtraFs != nil {
|
||||
for key := range sum.ExtraFs {
|
||||
fs := sum.ExtraFs[key]
|
||||
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
|
||||
fs.DiskUsed = twoDecimals(fs.DiskUsed / count)
|
||||
fs.DiskWritePs = twoDecimals(fs.DiskWritePs / count)
|
||||
fs.DiskReadPs = twoDecimals(fs.DiskReadPs / count)
|
||||
fs.DiskReadBytes = fs.DiskReadBytes / uint64(count)
|
||||
fs.DiskWriteBytes = fs.DiskWriteBytes / uint64(count)
|
||||
}
|
||||
}
|
||||
|
||||
// Average GPU data
|
||||
if sum.GPUData != nil {
|
||||
for id := range sum.GPUData {
|
||||
gpu := sum.GPUData[id]
|
||||
gpu.Temperature = twoDecimals(gpu.Temperature / count)
|
||||
gpu.MemoryUsed = twoDecimals(gpu.MemoryUsed / count)
|
||||
gpu.MemoryTotal = twoDecimals(gpu.MemoryTotal / count)
|
||||
gpu.Usage = twoDecimals(gpu.Usage / count)
|
||||
gpu.Power = twoDecimals(gpu.Power / count)
|
||||
gpu.Count = twoDecimals(gpu.Count / count)
|
||||
|
||||
if gpu.Engines != nil {
|
||||
for engineKey := range gpu.Engines {
|
||||
gpu.Engines[engineKey] = twoDecimals(gpu.Engines[engineKey] / count)
|
||||
}
|
||||
}
|
||||
|
||||
sum.GPUData[id] = gpu
|
||||
}
|
||||
}
|
||||
|
||||
// Average per-core usage
|
||||
if len(cpuCoresSums) > 0 {
|
||||
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
||||
for i := range cpuCoresSums {
|
||||
v := math.Round(float64(cpuCoresSums[i]) / count)
|
||||
avg[i] = uint8(v)
|
||||
}
|
||||
sum.CpuCoresUsage = avg
|
||||
}
|
||||
|
||||
// Average CPU breakdown
|
||||
if len(cpuBreakdownSums) > 0 {
|
||||
avg := make([]float64, len(cpuBreakdownSums))
|
||||
for i := range cpuBreakdownSums {
|
||||
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
||||
}
|
||||
sum.CpuBreakdown = avg
|
||||
// Average CPU breakdown
|
||||
if len(cpuBreakdownSums) > 0 {
|
||||
avg := make([]float64, len(cpuBreakdownSums))
|
||||
for i := range cpuBreakdownSums {
|
||||
avg[i] = twoDecimals(cpuBreakdownSums[i] / count)
|
||||
}
|
||||
sum.CpuBreakdown = avg
|
||||
}
|
||||
|
||||
return sum
|
||||
@@ -437,29 +451,33 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
||||
|
||||
// Calculate the average stats of a list of container_stats records
|
||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||
// Clear global map for reuse
|
||||
for k := range containerSums {
|
||||
delete(containerSums, k)
|
||||
}
|
||||
sums := containerSums
|
||||
count := float64(len(records))
|
||||
|
||||
for i := range records {
|
||||
id := records[i].Id
|
||||
// clear global statsRecord for reuse
|
||||
statsRecord.Stats = statsRecord.Stats[:0]
|
||||
// must set to nil (not [:0]) to avoid json.Unmarshal reusing backing array
|
||||
// which causes omitzero fields to inherit stale values from previous iterations
|
||||
containerStats = nil
|
||||
|
||||
queryParams["id"] = id
|
||||
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(queryParams).One(&statsRecord)
|
||||
|
||||
if err := json.Unmarshal(statsRecord.Stats, &containerStats); err != nil {
|
||||
allStats := make([][]container.Stats, 0, len(records))
|
||||
var row StatsRecord
|
||||
params := make(dbx.Params, 1)
|
||||
for _, rec := range records {
|
||||
row.Stats = row.Stats[:0]
|
||||
params["id"] = rec.Id
|
||||
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(params).One(&row)
|
||||
var cs []container.Stats
|
||||
if err := json.Unmarshal(row.Stats, &cs); err != nil {
|
||||
return []container.Stats{}
|
||||
}
|
||||
allStats = append(allStats, cs)
|
||||
}
|
||||
return AverageContainerStatsSlice(allStats)
|
||||
}
|
||||
|
||||
// AverageContainerStatsSlice computes the average of container stats across multiple time periods.
|
||||
func AverageContainerStatsSlice(records [][]container.Stats) []container.Stats {
|
||||
if len(records) == 0 {
|
||||
return []container.Stats{}
|
||||
}
|
||||
sums := make(map[string]*container.Stats)
|
||||
count := float64(len(records))
|
||||
|
||||
for _, containerStats := range records {
|
||||
for i := range containerStats {
|
||||
stat := containerStats[i]
|
||||
stat := &containerStats[i]
|
||||
if _, ok := sums[stat.Name]; !ok {
|
||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||
}
|
||||
@@ -488,131 +506,61 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
||||
return result
|
||||
}
|
||||
|
||||
// Delete old records
|
||||
func (rm *RecordManager) DeleteOldRecords() {
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
err := deleteOldSystemStats(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldContainerRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldSystemdServiceRecords(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = deleteOldQuietHours(txApp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
// AverageProbeStats averages probe stats across multiple records.
|
||||
// For each probe key: avg of avgs, min of mins, max of maxes, avg of losses.
|
||||
func (rm *RecordManager) AverageProbeStats(db dbx.Builder, records RecordIds) map[string]probe.Result {
|
||||
type probeValues struct {
|
||||
sums probe.Result
|
||||
count float64
|
||||
}
|
||||
|
||||
// Delete old alerts history records
|
||||
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||
db := app.DB()
|
||||
var users []struct {
|
||||
Id string `db:"user"`
|
||||
}
|
||||
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
query := db.NewQuery("SELECT stats FROM network_probe_stats WHERE id = {:id}")
|
||||
|
||||
// accumulate sums for each probe key across records
|
||||
sums := make(map[string]*probeValues)
|
||||
var row StatsRecord
|
||||
for _, rec := range records {
|
||||
row.Stats = row.Stats[:0]
|
||||
query.Bind(dbx.Params{"id": rec.Id}).One(&row)
|
||||
var rawStats map[string]probe.Result
|
||||
if err := json.Unmarshal(row.Stats, &rawStats); err != nil {
|
||||
continue
|
||||
}
|
||||
for key, vals := range rawStats {
|
||||
s, ok := sums[key]
|
||||
if !ok {
|
||||
s = &probeValues{sums: make(probe.Result, len(vals))}
|
||||
sums[key] = s
|
||||
}
|
||||
for i := range vals {
|
||||
switch i {
|
||||
case 1: // min fields
|
||||
if s.count == 0 || vals[i] < s.sums[i] {
|
||||
s.sums[i] = vals[i]
|
||||
}
|
||||
case 2: // max fields
|
||||
if vals[i] > s.sums[i] {
|
||||
s.sums[i] = vals[i]
|
||||
}
|
||||
default: // average fields
|
||||
s.sums[i] += vals[i]
|
||||
}
|
||||
}
|
||||
s.count++
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes system_stats records older than what is displayed in the UI
|
||||
func deleteOldSystemStats(app core.App) error {
|
||||
// Collections to process
|
||||
collections := [2]string{"system_stats", "container_stats"}
|
||||
|
||||
// Record types and their retention periods
|
||||
type RecordDeletionData struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
}
|
||||
recordData := []RecordDeletionData{
|
||||
{recordType: "1m", retention: time.Hour}, // 1 hour
|
||||
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
|
||||
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
|
||||
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
|
||||
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
for _, collection := range collections {
|
||||
// Build the WHERE clause
|
||||
var conditionParts []string
|
||||
var params dbx.Params = make(map[string]any)
|
||||
for i := range recordData {
|
||||
rd := recordData[i]
|
||||
// Create parameterized condition for this record type
|
||||
dateParam := fmt.Sprintf("date%d", i)
|
||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
||||
params[dateParam] = now.Add(-rd.retention)
|
||||
}
|
||||
// Combine conditions with OR
|
||||
conditionStr := strings.Join(conditionParts, " OR ")
|
||||
// Construct and execute the full raw query
|
||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
||||
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
||||
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||
// compute final averages
|
||||
result := make(map[string]probe.Result, len(sums))
|
||||
for key, s := range sums {
|
||||
if s.count == 0 {
|
||||
continue
|
||||
}
|
||||
s.sums[0] = twoDecimals(s.sums[0] / s.count) // avg latency
|
||||
s.sums[3] = twoDecimals(s.sums[3] / s.count) // packet loss
|
||||
result[key] = s.sums
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
||||
func deleteOldSystemdServiceRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
||||
|
||||
// Delete systemd service records where updated < twentyMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes container records that haven't been updated in the last 10 minutes
|
||||
func deleteOldContainerRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
|
||||
// Delete container records where updated < tenMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old container records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes old quiet hours records where end date has passed
|
||||
func deleteOldQuietHours(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return result
|
||||
}
|
||||
|
||||
/* Round float to two decimals */
|
||||
|
||||
820
internal/records/records_averaging_test.go
Normal file
820
internal/records/records_averaging_test.go
Normal file
@@ -0,0 +1,820 @@
|
||||
//go:build testing
|
||||
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/container"
|
||||
"github.com/henrygd/beszel/internal/entities/system"
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAverageSystemStatsSlice_Empty(t *testing.T) {
|
||||
result := records.AverageSystemStatsSlice(nil)
|
||||
assert.Equal(t, system.Stats{}, result)
|
||||
|
||||
result = records.AverageSystemStatsSlice([]system.Stats{})
|
||||
assert.Equal(t, system.Stats{}, result)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_SingleRecord(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 45.67,
|
||||
Mem: 16.0,
|
||||
MemUsed: 8.5,
|
||||
MemPct: 53.12,
|
||||
MemBuffCache: 2.0,
|
||||
Swap: 4.0,
|
||||
SwapUsed: 1.0,
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 250.0,
|
||||
DiskPct: 50.0,
|
||||
DiskReadPs: 100.5,
|
||||
DiskWritePs: 200.75,
|
||||
NetworkSent: 10.5,
|
||||
NetworkRecv: 20.25,
|
||||
LoadAvg: [3]float64{1.5, 2.0, 3.5},
|
||||
Bandwidth: [2]uint64{1000, 2000},
|
||||
DiskIO: [2]uint64{500, 600},
|
||||
Battery: [2]uint8{80, 1},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 45.67, result.Cpu)
|
||||
assert.Equal(t, 16.0, result.Mem)
|
||||
assert.Equal(t, 8.5, result.MemUsed)
|
||||
assert.Equal(t, 53.12, result.MemPct)
|
||||
assert.Equal(t, 2.0, result.MemBuffCache)
|
||||
assert.Equal(t, 4.0, result.Swap)
|
||||
assert.Equal(t, 1.0, result.SwapUsed)
|
||||
assert.Equal(t, 500.0, result.DiskTotal)
|
||||
assert.Equal(t, 250.0, result.DiskUsed)
|
||||
assert.Equal(t, 50.0, result.DiskPct)
|
||||
assert.Equal(t, 100.5, result.DiskReadPs)
|
||||
assert.Equal(t, 200.75, result.DiskWritePs)
|
||||
assert.Equal(t, 10.5, result.NetworkSent)
|
||||
assert.Equal(t, 20.25, result.NetworkRecv)
|
||||
assert.Equal(t, [3]float64{1.5, 2.0, 3.5}, result.LoadAvg)
|
||||
assert.Equal(t, [2]uint64{1000, 2000}, result.Bandwidth)
|
||||
assert.Equal(t, [2]uint64{500, 600}, result.DiskIO)
|
||||
assert.Equal(t, uint8(80), result.Battery[0])
|
||||
assert.Equal(t, uint8(1), result.Battery[1])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_BasicAveraging(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 20.0,
|
||||
Mem: 16.0,
|
||||
MemUsed: 6.0,
|
||||
MemPct: 37.5,
|
||||
MemBuffCache: 1.0,
|
||||
MemZfsArc: 0.5,
|
||||
Swap: 4.0,
|
||||
SwapUsed: 1.0,
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 200.0,
|
||||
DiskPct: 40.0,
|
||||
DiskReadPs: 100.0,
|
||||
DiskWritePs: 200.0,
|
||||
NetworkSent: 10.0,
|
||||
NetworkRecv: 20.0,
|
||||
LoadAvg: [3]float64{1.0, 2.0, 3.0},
|
||||
Bandwidth: [2]uint64{1000, 2000},
|
||||
DiskIO: [2]uint64{400, 600},
|
||||
Battery: [2]uint8{80, 1},
|
||||
},
|
||||
{
|
||||
Cpu: 40.0,
|
||||
Mem: 16.0,
|
||||
MemUsed: 10.0,
|
||||
MemPct: 62.5,
|
||||
MemBuffCache: 3.0,
|
||||
MemZfsArc: 1.5,
|
||||
Swap: 4.0,
|
||||
SwapUsed: 3.0,
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 300.0,
|
||||
DiskPct: 60.0,
|
||||
DiskReadPs: 200.0,
|
||||
DiskWritePs: 400.0,
|
||||
NetworkSent: 30.0,
|
||||
NetworkRecv: 40.0,
|
||||
LoadAvg: [3]float64{3.0, 4.0, 5.0},
|
||||
Bandwidth: [2]uint64{3000, 4000},
|
||||
DiskIO: [2]uint64{600, 800},
|
||||
Battery: [2]uint8{60, 1},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 30.0, result.Cpu)
|
||||
assert.Equal(t, 16.0, result.Mem)
|
||||
assert.Equal(t, 8.0, result.MemUsed)
|
||||
assert.Equal(t, 50.0, result.MemPct)
|
||||
assert.Equal(t, 2.0, result.MemBuffCache)
|
||||
assert.Equal(t, 1.0, result.MemZfsArc)
|
||||
assert.Equal(t, 4.0, result.Swap)
|
||||
assert.Equal(t, 2.0, result.SwapUsed)
|
||||
assert.Equal(t, 500.0, result.DiskTotal)
|
||||
assert.Equal(t, 250.0, result.DiskUsed)
|
||||
assert.Equal(t, 50.0, result.DiskPct)
|
||||
assert.Equal(t, 150.0, result.DiskReadPs)
|
||||
assert.Equal(t, 300.0, result.DiskWritePs)
|
||||
assert.Equal(t, 20.0, result.NetworkSent)
|
||||
assert.Equal(t, 30.0, result.NetworkRecv)
|
||||
assert.Equal(t, [3]float64{2.0, 3.0, 4.0}, result.LoadAvg)
|
||||
assert.Equal(t, [2]uint64{2000, 3000}, result.Bandwidth)
|
||||
assert.Equal(t, [2]uint64{500, 700}, result.DiskIO)
|
||||
assert.Equal(t, uint8(70), result.Battery[0])
|
||||
assert.Equal(t, uint8(1), result.Battery[1])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_PeakValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 20.0,
|
||||
MaxCpu: 25.0,
|
||||
MemUsed: 6.0,
|
||||
MaxMem: 7.0,
|
||||
NetworkSent: 10.0,
|
||||
MaxNetworkSent: 15.0,
|
||||
NetworkRecv: 20.0,
|
||||
MaxNetworkRecv: 25.0,
|
||||
DiskReadPs: 100.0,
|
||||
MaxDiskReadPs: 120.0,
|
||||
DiskWritePs: 200.0,
|
||||
MaxDiskWritePs: 220.0,
|
||||
Bandwidth: [2]uint64{1000, 2000},
|
||||
MaxBandwidth: [2]uint64{1500, 2500},
|
||||
DiskIO: [2]uint64{400, 600},
|
||||
MaxDiskIO: [2]uint64{500, 700},
|
||||
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||
MaxDiskIoStats: [6]float64{15.0, 25.0, 35.0, 6.0, 9.0, 14.0},
|
||||
},
|
||||
{
|
||||
Cpu: 40.0,
|
||||
MaxCpu: 50.0,
|
||||
MemUsed: 10.0,
|
||||
MaxMem: 12.0,
|
||||
NetworkSent: 30.0,
|
||||
MaxNetworkSent: 35.0,
|
||||
NetworkRecv: 40.0,
|
||||
MaxNetworkRecv: 45.0,
|
||||
DiskReadPs: 200.0,
|
||||
MaxDiskReadPs: 210.0,
|
||||
DiskWritePs: 400.0,
|
||||
MaxDiskWritePs: 410.0,
|
||||
Bandwidth: [2]uint64{3000, 4000},
|
||||
MaxBandwidth: [2]uint64{3500, 4500},
|
||||
DiskIO: [2]uint64{600, 800},
|
||||
MaxDiskIO: [2]uint64{650, 850},
|
||||
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
|
||||
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 50.0, result.MaxCpu)
|
||||
assert.Equal(t, 12.0, result.MaxMem)
|
||||
assert.Equal(t, 35.0, result.MaxNetworkSent)
|
||||
assert.Equal(t, 45.0, result.MaxNetworkRecv)
|
||||
assert.Equal(t, 210.0, result.MaxDiskReadPs)
|
||||
assert.Equal(t, 410.0, result.MaxDiskWritePs)
|
||||
assert.Equal(t, [2]uint64{3500, 4500}, result.MaxBandwidth)
|
||||
assert.Equal(t, [2]uint64{650, 850}, result.MaxDiskIO)
|
||||
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, result.DiskIoStats)
|
||||
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, result.MaxDiskIoStats)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_DiskIoStats(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
DiskIoStats: [6]float64{30.0, 40.0, 50.0, 15.0, 18.0, 22.0},
|
||||
MaxDiskIoStats: [6]float64{28.0, 38.0, 48.0, 14.0, 17.0, 21.0},
|
||||
},
|
||||
{
|
||||
Cpu: 30.0,
|
||||
DiskIoStats: [6]float64{20.0, 30.0, 40.0, 10.0, 12.0, 16.0},
|
||||
MaxDiskIoStats: [6]float64{25.0, 35.0, 45.0, 11.0, 13.0, 17.0},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
// Average: (10+30+20)/3=20, (20+40+30)/3=30, (30+50+40)/3=40, (5+15+10)/3=10, (8+18+12)/3≈12.67, (12+22+16)/3≈16.67
|
||||
assert.Equal(t, 20.0, result.DiskIoStats[0])
|
||||
assert.Equal(t, 30.0, result.DiskIoStats[1])
|
||||
assert.Equal(t, 40.0, result.DiskIoStats[2])
|
||||
assert.Equal(t, 10.0, result.DiskIoStats[3])
|
||||
assert.Equal(t, 12.67, result.DiskIoStats[4])
|
||||
assert.Equal(t, 16.67, result.DiskIoStats[5])
|
||||
// Max: current DiskIoStats[0] wins for record 2 (30 > MaxDiskIoStats 28)
|
||||
assert.Equal(t, 30.0, result.MaxDiskIoStats[0])
|
||||
assert.Equal(t, 40.0, result.MaxDiskIoStats[1])
|
||||
assert.Equal(t, 50.0, result.MaxDiskIoStats[2])
|
||||
assert.Equal(t, 15.0, result.MaxDiskIoStats[3])
|
||||
assert.Equal(t, 18.0, result.MaxDiskIoStats[4])
|
||||
assert.Equal(t, 22.0, result.MaxDiskIoStats[5])
|
||||
}
|
||||
|
||||
// Tests that current DiskIoStats values are considered when computing MaxDiskIoStats.
|
||||
func TestAverageSystemStatsSlice_DiskIoStatsPeakFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0}},
|
||||
{Cpu: 20.0, DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0}, MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
// Current value from first record (95, 90, 85, 50, 60, 80) beats MaxDiskIoStats in both records
|
||||
assert.Equal(t, 95.0, result.MaxDiskIoStats[0])
|
||||
assert.Equal(t, 90.0, result.MaxDiskIoStats[1])
|
||||
assert.Equal(t, 85.0, result.MaxDiskIoStats[2])
|
||||
assert.Equal(t, 50.0, result.MaxDiskIoStats[3])
|
||||
assert.Equal(t, 60.0, result.MaxDiskIoStats[4])
|
||||
assert.Equal(t, 80.0, result.MaxDiskIoStats[5])
|
||||
}
|
||||
|
||||
// Tests that current values are considered when computing peaks
|
||||
// (i.e., current cpu > MaxCpu should still win).
|
||||
func TestAverageSystemStatsSlice_PeakFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 95.0, MaxCpu: 80.0, MemUsed: 15.0, MaxMem: 10.0},
|
||||
{Cpu: 10.0, MaxCpu: 20.0, MemUsed: 5.0, MaxMem: 8.0},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 95.0, result.MaxCpu)
|
||||
assert.Equal(t, 15.0, result.MaxMem)
|
||||
}
|
||||
|
||||
// Tests that records without temperature data are excluded from the temperature average.
|
||||
func TestAverageSystemStatsSlice_Temperatures(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
Temperatures: map[string]float64{"cpu": 60.0, "gpu": 70.0},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
Temperatures: map[string]float64{"cpu": 80.0, "gpu": 90.0},
|
||||
},
|
||||
{
|
||||
// No temperatures - should not affect temp averaging
|
||||
Cpu: 30.0,
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.Temperatures)
|
||||
// Average over 2 records that had temps, not 3
|
||||
assert.Equal(t, 70.0, result.Temperatures["cpu"])
|
||||
assert.Equal(t, 80.0, result.Temperatures["gpu"])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_NetworkInterfaces(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
NetworkInterfaces: map[string][4]uint64{
|
||||
"eth0": {100, 200, 150, 250},
|
||||
"eth1": {50, 60, 70, 80},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
NetworkInterfaces: map[string][4]uint64{
|
||||
"eth0": {200, 400, 300, 500},
|
||||
"eth1": {150, 160, 170, 180},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.NetworkInterfaces)
|
||||
// [0] and [1] are averaged, [2] and [3] are max
|
||||
assert.Equal(t, [4]uint64{150, 300, 300, 500}, result.NetworkInterfaces["eth0"])
|
||||
assert.Equal(t, [4]uint64{100, 110, 170, 180}, result.NetworkInterfaces["eth1"])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_ExtraFs(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskTotal: 1000.0,
|
||||
DiskUsed: 400.0,
|
||||
DiskReadPs: 50.0,
|
||||
DiskWritePs: 100.0,
|
||||
MaxDiskReadPS: 60.0,
|
||||
MaxDiskWritePS: 110.0,
|
||||
DiskReadBytes: 5000,
|
||||
DiskWriteBytes: 10000,
|
||||
MaxDiskReadBytes: 6000,
|
||||
MaxDiskWriteBytes: 11000,
|
||||
DiskIoStats: [6]float64{10.0, 20.0, 30.0, 5.0, 8.0, 12.0},
|
||||
MaxDiskIoStats: [6]float64{12.0, 22.0, 32.0, 6.0, 9.0, 13.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskTotal: 1000.0,
|
||||
DiskUsed: 600.0,
|
||||
DiskReadPs: 150.0,
|
||||
DiskWritePs: 200.0,
|
||||
MaxDiskReadPS: 160.0,
|
||||
MaxDiskWritePS: 210.0,
|
||||
DiskReadBytes: 15000,
|
||||
DiskWriteBytes: 20000,
|
||||
MaxDiskReadBytes: 16000,
|
||||
MaxDiskWriteBytes: 21000,
|
||||
DiskIoStats: [6]float64{50.0, 60.0, 70.0, 15.0, 18.0, 22.0},
|
||||
MaxDiskIoStats: [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.ExtraFs)
|
||||
require.NotNil(t, result.ExtraFs["/data"])
|
||||
fs := result.ExtraFs["/data"]
|
||||
assert.Equal(t, 1000.0, fs.DiskTotal)
|
||||
assert.Equal(t, 500.0, fs.DiskUsed)
|
||||
assert.Equal(t, 100.0, fs.DiskReadPs)
|
||||
assert.Equal(t, 150.0, fs.DiskWritePs)
|
||||
assert.Equal(t, 160.0, fs.MaxDiskReadPS)
|
||||
assert.Equal(t, 210.0, fs.MaxDiskWritePS)
|
||||
assert.Equal(t, uint64(10000), fs.DiskReadBytes)
|
||||
assert.Equal(t, uint64(15000), fs.DiskWriteBytes)
|
||||
assert.Equal(t, uint64(16000), fs.MaxDiskReadBytes)
|
||||
assert.Equal(t, uint64(21000), fs.MaxDiskWriteBytes)
|
||||
assert.Equal(t, [6]float64{30.0, 40.0, 50.0, 10.0, 13.0, 17.0}, fs.DiskIoStats)
|
||||
assert.Equal(t, [6]float64{55.0, 65.0, 75.0, 16.0, 19.0, 23.0}, fs.MaxDiskIoStats)
|
||||
}
|
||||
|
||||
// Tests that ExtraFs DiskIoStats peak considers current values, not just previous peaks.
|
||||
func TestAverageSystemStatsSlice_ExtraFsDiskIoStatsPeakFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskIoStats: [6]float64{95.0, 90.0, 85.0, 50.0, 60.0, 80.0}, // exceeds MaxDiskIoStats
|
||||
MaxDiskIoStats: [6]float64{80.0, 80.0, 80.0, 40.0, 50.0, 70.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskIoStats: [6]float64{10.0, 10.0, 10.0, 5.0, 6.0, 8.0},
|
||||
MaxDiskIoStats: [6]float64{20.0, 20.0, 20.0, 10.0, 12.0, 16.0},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
fs := result.ExtraFs["/data"]
|
||||
assert.Equal(t, 95.0, fs.MaxDiskIoStats[0])
|
||||
assert.Equal(t, 90.0, fs.MaxDiskIoStats[1])
|
||||
assert.Equal(t, 85.0, fs.MaxDiskIoStats[2])
|
||||
assert.Equal(t, 50.0, fs.MaxDiskIoStats[3])
|
||||
assert.Equal(t, 60.0, fs.MaxDiskIoStats[4])
|
||||
assert.Equal(t, 80.0, fs.MaxDiskIoStats[5])
|
||||
}
|
||||
|
||||
// Tests that extra FS peak values consider current values, not just previous peaks.
|
||||
func TestAverageSystemStatsSlice_ExtraFsPeaksFromCurrentValues(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskReadPs: 500.0, // exceeds MaxDiskReadPS
|
||||
MaxDiskReadPS: 100.0,
|
||||
DiskReadBytes: 50000,
|
||||
MaxDiskReadBytes: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
ExtraFs: map[string]*system.FsStats{
|
||||
"/data": {
|
||||
DiskReadPs: 50.0,
|
||||
MaxDiskReadPS: 200.0,
|
||||
DiskReadBytes: 5000,
|
||||
MaxDiskReadBytes: 20000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
fs := result.ExtraFs["/data"]
|
||||
assert.Equal(t, 500.0, fs.MaxDiskReadPS)
|
||||
assert.Equal(t, uint64(50000), fs.MaxDiskReadBytes)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_GPUData(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {
|
||||
Name: "RTX 4090",
|
||||
Temperature: 60.0,
|
||||
MemoryUsed: 4.0,
|
||||
MemoryTotal: 24.0,
|
||||
Usage: 30.0,
|
||||
Power: 200.0,
|
||||
Count: 1.0,
|
||||
Engines: map[string]float64{
|
||||
"3D": 50.0,
|
||||
"Video": 10.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {
|
||||
Name: "RTX 4090",
|
||||
Temperature: 80.0,
|
||||
MemoryUsed: 8.0,
|
||||
MemoryTotal: 24.0,
|
||||
Usage: 70.0,
|
||||
Power: 300.0,
|
||||
Count: 1.0,
|
||||
Engines: map[string]float64{
|
||||
"3D": 90.0,
|
||||
"Video": 30.0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.GPUData)
|
||||
gpu := result.GPUData["gpu0"]
|
||||
assert.Equal(t, "RTX 4090", gpu.Name)
|
||||
assert.Equal(t, 70.0, gpu.Temperature)
|
||||
assert.Equal(t, 6.0, gpu.MemoryUsed)
|
||||
assert.Equal(t, 24.0, gpu.MemoryTotal)
|
||||
assert.Equal(t, 50.0, gpu.Usage)
|
||||
assert.Equal(t, 250.0, gpu.Power)
|
||||
assert.Equal(t, 1.0, gpu.Count)
|
||||
require.NotNil(t, gpu.Engines)
|
||||
assert.Equal(t, 70.0, gpu.Engines["3D"])
|
||||
assert.Equal(t, 20.0, gpu.Engines["Video"])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_MultipleGPUs(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {Name: "GPU A", Usage: 20.0, Temperature: 50.0},
|
||||
"gpu1": {Name: "GPU B", Usage: 60.0, Temperature: 70.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {Name: "GPU A", Usage: 40.0, Temperature: 60.0},
|
||||
"gpu1": {Name: "GPU B", Usage: 80.0, Temperature: 80.0},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.GPUData)
|
||||
assert.Equal(t, 30.0, result.GPUData["gpu0"].Usage)
|
||||
assert.Equal(t, 55.0, result.GPUData["gpu0"].Temperature)
|
||||
assert.Equal(t, 70.0, result.GPUData["gpu1"].Usage)
|
||||
assert.Equal(t, 75.0, result.GPUData["gpu1"].Temperature)
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_CpuCoresUsage(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{10, 20, 30, 40}},
|
||||
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{30, 40, 50, 60}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.CpuCoresUsage)
|
||||
assert.Equal(t, system.Uint8Slice{20, 30, 40, 50}, result.CpuCoresUsage)
|
||||
}
|
||||
|
||||
// Tests that per-core usage rounds correctly (e.g., 15.5 -> 16 via math.Round).
|
||||
func TestAverageSystemStatsSlice_CpuCoresUsageRounding(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, CpuCoresUsage: system.Uint8Slice{11}},
|
||||
{Cpu: 20.0, CpuCoresUsage: system.Uint8Slice{20}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.CpuCoresUsage)
|
||||
// (11+20)/2 = 15.5, rounds to 16
|
||||
assert.Equal(t, uint8(16), result.CpuCoresUsage[0])
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_CpuBreakdown(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5}},
|
||||
{Cpu: 20.0, CpuBreakdown: []float64{15.0, 7.0, 3.0, 1.5, 73.5}},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
require.NotNil(t, result.CpuBreakdown)
|
||||
assert.Equal(t, []float64{10.0, 5.0, 2.0, 1.0, 82.0}, result.CpuBreakdown)
|
||||
}
|
||||
|
||||
// Tests that Battery[1] (charge state) uses the last record's value.
|
||||
func TestAverageSystemStatsSlice_BatteryLastChargeState(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, Battery: [2]uint8{100, 1}}, // charging
|
||||
{Cpu: 20.0, Battery: [2]uint8{90, 0}}, // not charging
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, uint8(95), result.Battery[0])
|
||||
assert.Equal(t, uint8(0), result.Battery[1]) // last record's charge state
|
||||
}
|
||||
|
||||
func TestAverageSystemStatsSlice_ThreeRecordsRounding(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{Cpu: 10.0, Mem: 8.0},
|
||||
{Cpu: 20.0, Mem: 8.0},
|
||||
{Cpu: 30.0, Mem: 8.0},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 20.0, result.Cpu)
|
||||
assert.Equal(t, 8.0, result.Mem)
|
||||
}
|
||||
|
||||
// Tests records where some have optional fields and others don't.
|
||||
func TestAverageSystemStatsSlice_MixedOptionalFields(t *testing.T) {
|
||||
input := []system.Stats{
|
||||
{
|
||||
Cpu: 10.0,
|
||||
CpuCoresUsage: system.Uint8Slice{50, 60},
|
||||
CpuBreakdown: []float64{5.0, 3.0, 1.0, 0.5, 90.5},
|
||||
GPUData: map[string]system.GPUData{
|
||||
"gpu0": {Name: "GPU", Usage: 40.0},
|
||||
},
|
||||
},
|
||||
{
|
||||
Cpu: 20.0,
|
||||
// No CpuCoresUsage, CpuBreakdown, or GPUData
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 15.0, result.Cpu)
|
||||
// CpuCoresUsage: only 1 record had it, so sum/2
|
||||
require.NotNil(t, result.CpuCoresUsage)
|
||||
assert.Equal(t, uint8(25), result.CpuCoresUsage[0])
|
||||
assert.Equal(t, uint8(30), result.CpuCoresUsage[1])
|
||||
// CpuBreakdown: only 1 record had it, so sum/2
|
||||
require.NotNil(t, result.CpuBreakdown)
|
||||
assert.Equal(t, 2.5, result.CpuBreakdown[0])
|
||||
// GPUData: only 1 record had it, so sum/2
|
||||
require.NotNil(t, result.GPUData)
|
||||
assert.Equal(t, 20.0, result.GPUData["gpu0"].Usage)
|
||||
}
|
||||
|
||||
// Tests with 10 records matching the common real-world case (10 x 1m -> 1 x 10m).
|
||||
func TestAverageSystemStatsSlice_TenRecords(t *testing.T) {
|
||||
input := make([]system.Stats, 10)
|
||||
for i := range input {
|
||||
input[i] = system.Stats{
|
||||
Cpu: float64(i * 10), // 0, 10, 20, ..., 90
|
||||
Mem: 16.0,
|
||||
MemUsed: float64(4 + i), // 4, 5, 6, ..., 13
|
||||
MemPct: float64(25 + i), // 25, 26, ..., 34
|
||||
DiskTotal: 500.0,
|
||||
DiskUsed: 250.0,
|
||||
DiskPct: 50.0,
|
||||
NetworkSent: float64(i),
|
||||
NetworkRecv: float64(i * 2),
|
||||
Bandwidth: [2]uint64{uint64(i * 1000), uint64(i * 2000)},
|
||||
LoadAvg: [3]float64{float64(i), float64(i) * 0.5, float64(i) * 0.25},
|
||||
}
|
||||
}
|
||||
|
||||
result := records.AverageSystemStatsSlice(input)
|
||||
|
||||
assert.Equal(t, 45.0, result.Cpu) // avg of 0..90
|
||||
assert.Equal(t, 16.0, result.Mem) // constant
|
||||
assert.Equal(t, 8.5, result.MemUsed) // avg of 4..13
|
||||
assert.Equal(t, 29.5, result.MemPct) // avg of 25..34
|
||||
assert.Equal(t, 500.0, result.DiskTotal)
|
||||
assert.Equal(t, 250.0, result.DiskUsed)
|
||||
assert.Equal(t, 50.0, result.DiskPct)
|
||||
assert.Equal(t, 4.5, result.NetworkSent)
|
||||
assert.Equal(t, 9.0, result.NetworkRecv)
|
||||
assert.Equal(t, [2]uint64{4500, 9000}, result.Bandwidth)
|
||||
}
|
||||
|
||||
// --- Container Stats Tests ---
|
||||
|
||||
func TestAverageContainerStatsSlice_Empty(t *testing.T) {
|
||||
result := records.AverageContainerStatsSlice(nil)
|
||||
assert.Equal(t, []container.Stats{}, result)
|
||||
|
||||
result = records.AverageContainerStatsSlice([][]container.Stats{})
|
||||
assert.Equal(t, []container.Stats{}, result)
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_SingleRecord(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 5.0, Mem: 128.0, Bandwidth: [2]uint64{1000, 2000}},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
assert.Equal(t, 5.0, result[0].Cpu)
|
||||
assert.Equal(t, 128.0, result[0].Mem)
|
||||
assert.Equal(t, [2]uint64{1000, 2000}, result[0].Bandwidth)
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_BasicAveraging(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{1000, 2000}},
|
||||
{Name: "redis", Cpu: 5.0, Mem: 64.0, Bandwidth: [2]uint64{500, 1000}},
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{3000, 4000}},
|
||||
{Name: "redis", Cpu: 15.0, Mem: 128.0, Bandwidth: [2]uint64{1500, 2000}},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
|
||||
require.Len(t, result, 2)
|
||||
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
assert.Equal(t, 15.0, result[0].Cpu)
|
||||
assert.Equal(t, 150.0, result[0].Mem)
|
||||
assert.Equal(t, [2]uint64{2000, 3000}, result[0].Bandwidth)
|
||||
|
||||
assert.Equal(t, "redis", result[1].Name)
|
||||
assert.Equal(t, 10.0, result[1].Cpu)
|
||||
assert.Equal(t, 96.0, result[1].Mem)
|
||||
assert.Equal(t, [2]uint64{1000, 1500}, result[1].Bandwidth)
|
||||
}
|
||||
|
||||
// Tests containers that appear in some records but not all.
|
||||
func TestAverageContainerStatsSlice_ContainerAppearsInSomeRecords(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0},
|
||||
{Name: "redis", Cpu: 5.0, Mem: 64.0},
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0},
|
||||
// redis not present
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
|
||||
require.Len(t, result, 2)
|
||||
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
assert.Equal(t, 15.0, result[0].Cpu)
|
||||
assert.Equal(t, 150.0, result[0].Mem)
|
||||
|
||||
// redis: sum / count where count = total records (2), not records containing redis
|
||||
assert.Equal(t, "redis", result[1].Name)
|
||||
assert.Equal(t, 2.5, result[1].Cpu)
|
||||
assert.Equal(t, 32.0, result[1].Mem)
|
||||
}
|
||||
|
||||
// Tests backward compatibility with deprecated NetworkSent/NetworkRecv (MB) when Bandwidth is zero.
|
||||
func TestAverageContainerStatsSlice_DeprecatedNetworkFields(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0, NetworkSent: 1.0, NetworkRecv: 2.0}, // 1 MB, 2 MB
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0, NetworkSent: 3.0, NetworkRecv: 4.0}, // 3 MB, 4 MB
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, "nginx", result[0].Name)
|
||||
// avg sent = (1*1048576 + 3*1048576) / 2 = 2*1048576
|
||||
assert.Equal(t, uint64(2*1048576), result[0].Bandwidth[0])
|
||||
// avg recv = (2*1048576 + 4*1048576) / 2 = 3*1048576
|
||||
assert.Equal(t, uint64(3*1048576), result[0].Bandwidth[1])
|
||||
}
|
||||
|
||||
// Tests that when Bandwidth is set, deprecated NetworkSent/NetworkRecv are ignored.
|
||||
func TestAverageContainerStatsSlice_MixedBandwidthAndDeprecated(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "nginx", Cpu: 10.0, Mem: 100.0, Bandwidth: [2]uint64{5000, 6000}, NetworkSent: 99.0, NetworkRecv: 99.0},
|
||||
},
|
||||
{
|
||||
{Name: "nginx", Cpu: 20.0, Mem: 200.0, Bandwidth: [2]uint64{7000, 8000}},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, uint64(6000), result[0].Bandwidth[0])
|
||||
assert.Equal(t, uint64(7000), result[0].Bandwidth[1])
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_ThreeRecords(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{{Name: "app", Cpu: 1.0, Mem: 100.0}},
|
||||
{{Name: "app", Cpu: 2.0, Mem: 200.0}},
|
||||
{{Name: "app", Cpu: 3.0, Mem: 300.0}},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
|
||||
require.Len(t, result, 1)
|
||||
assert.Equal(t, 2.0, result[0].Cpu)
|
||||
assert.Equal(t, 200.0, result[0].Mem)
|
||||
}
|
||||
|
||||
func TestAverageContainerStatsSlice_ManyContainers(t *testing.T) {
|
||||
input := [][]container.Stats{
|
||||
{
|
||||
{Name: "a", Cpu: 10.0, Mem: 100.0},
|
||||
{Name: "b", Cpu: 20.0, Mem: 200.0},
|
||||
{Name: "c", Cpu: 30.0, Mem: 300.0},
|
||||
{Name: "d", Cpu: 40.0, Mem: 400.0},
|
||||
},
|
||||
{
|
||||
{Name: "a", Cpu: 20.0, Mem: 200.0},
|
||||
{Name: "b", Cpu: 30.0, Mem: 300.0},
|
||||
{Name: "c", Cpu: 40.0, Mem: 400.0},
|
||||
{Name: "d", Cpu: 50.0, Mem: 500.0},
|
||||
},
|
||||
}
|
||||
|
||||
result := records.AverageContainerStatsSlice(input)
|
||||
sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name })
|
||||
|
||||
require.Len(t, result, 4)
|
||||
assert.Equal(t, 15.0, result[0].Cpu)
|
||||
assert.Equal(t, 25.0, result[1].Cpu)
|
||||
assert.Equal(t, 35.0, result[2].Cpu)
|
||||
assert.Equal(t, 45.0, result[3].Cpu)
|
||||
}
|
||||
138
internal/records/records_deletion.go
Normal file
138
internal/records/records_deletion.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package records
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
)
|
||||
|
||||
// Delete old records
|
||||
func (rm *RecordManager) DeleteOldRecords() {
|
||||
rm.app.RunInTransaction(func(txApp core.App) error {
|
||||
err := deleteOldSystemStats(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old system stats", "err", err)
|
||||
}
|
||||
err = deleteOldContainerRecords(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old container records", "err", err)
|
||||
}
|
||||
err = deleteOldSystemdServiceRecords(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old systemd service records", "err", err)
|
||||
}
|
||||
err = deleteOldAlertsHistory(txApp, 200, 250)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old alerts history", "err", err)
|
||||
}
|
||||
err = deleteOldQuietHours(txApp)
|
||||
if err != nil {
|
||||
slog.Error("Error deleting old quiet hours", "err", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Delete old alerts history records
|
||||
func deleteOldAlertsHistory(app core.App, countToKeep, countBeforeDeletion int) error {
|
||||
db := app.DB()
|
||||
var users []struct {
|
||||
Id string `db:"user"`
|
||||
}
|
||||
err := db.NewQuery("SELECT user, COUNT(*) as count FROM alerts_history GROUP BY user HAVING count > {:countBeforeDeletion}").Bind(dbx.Params{"countBeforeDeletion": countBeforeDeletion}).All(&users)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, user := range users {
|
||||
_, err = db.NewQuery("DELETE FROM alerts_history WHERE user = {:user} AND id NOT IN (SELECT id FROM alerts_history WHERE user = {:user} ORDER BY created DESC LIMIT {:countToKeep})").Bind(dbx.Params{"user": user.Id, "countToKeep": countToKeep}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes system_stats records older than what is displayed in the UI
|
||||
func deleteOldSystemStats(app core.App) error {
|
||||
// Collections to process
|
||||
collections := [3]string{"system_stats", "container_stats", "network_probe_stats"}
|
||||
|
||||
// Record types and their retention periods
|
||||
type RecordDeletionData struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
}
|
||||
recordData := []RecordDeletionData{
|
||||
{recordType: "1m", retention: time.Hour}, // 1 hour
|
||||
{recordType: "10m", retention: 12 * time.Hour}, // 12 hours
|
||||
{recordType: "20m", retention: 24 * time.Hour}, // 1 day
|
||||
{recordType: "120m", retention: 7 * 24 * time.Hour}, // 7 days
|
||||
{recordType: "480m", retention: 30 * 24 * time.Hour}, // 30 days
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
for _, collection := range collections {
|
||||
// Build the WHERE clause
|
||||
var conditionParts []string
|
||||
var params dbx.Params = make(map[string]any)
|
||||
for i := range recordData {
|
||||
rd := recordData[i]
|
||||
// Create parameterized condition for this record type
|
||||
dateParam := fmt.Sprintf("date%d", i)
|
||||
conditionParts = append(conditionParts, fmt.Sprintf("(type = '%s' AND created < {:%s})", rd.recordType, dateParam))
|
||||
params[dateParam] = now.Add(-rd.retention)
|
||||
}
|
||||
// Combine conditions with OR
|
||||
conditionStr := strings.Join(conditionParts, " OR ")
|
||||
// Construct and execute the full raw query
|
||||
rawQuery := fmt.Sprintf("DELETE FROM %s WHERE %s", collection, conditionStr)
|
||||
if _, err := app.DB().NewQuery(rawQuery).Bind(params).Execute(); err != nil {
|
||||
return fmt.Errorf("failed to delete from %s: %v", collection, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes systemd service records that haven't been updated in the last 20 minutes
|
||||
func deleteOldSystemdServiceRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
twentyMinutesAgo := now.Add(-20 * time.Minute)
|
||||
|
||||
// Delete systemd service records where updated < twentyMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM systemd_services WHERE updated < {:updated}").Bind(dbx.Params{"updated": twentyMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old systemd service records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes container records that haven't been updated in the last 10 minutes
|
||||
func deleteOldContainerRecords(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
tenMinutesAgo := now.Add(-10 * time.Minute)
|
||||
|
||||
// Delete container records where updated < tenMinutesAgo
|
||||
_, err := app.DB().NewQuery("DELETE FROM containers WHERE updated < {:updated}").Bind(dbx.Params{"updated": tenMinutesAgo.UnixMilli()}).Execute()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete old container records: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deletes old quiet hours records where end date has passed
|
||||
func deleteOldQuietHours(app core.App) error {
|
||||
now := time.Now().UTC()
|
||||
_, err := app.DB().NewQuery("DELETE FROM quiet_hours WHERE type = 'one-time' AND end < {:now}").Bind(dbx.Params{"now": now}).Execute()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
428
internal/records/records_deletion_test.go
Normal file
428
internal/records/records_deletion_test.go
Normal file
@@ -0,0 +1,428 @@
|
||||
//go:build testing
|
||||
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||
func TestDeleteOldRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user for alerts history
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test system
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create old system_stats records that should be deleted
|
||||
var record *core.Record
|
||||
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// created is autodate field, so we need to set it manually
|
||||
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record)
|
||||
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||
require.Equal(t, record.Get("system"), system.Id)
|
||||
require.Equal(t, record.Get("type"), "1m")
|
||||
|
||||
// Create recent system_stats record that should be kept
|
||||
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create many alerts history records to trigger deletion
|
||||
for i := range 260 { // More than countBeforeDeletion (250)
|
||||
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count records before deletion
|
||||
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run deletion
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify old system stats were deleted
|
||||
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||
|
||||
// Verify alerts history was trimmed
|
||||
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||
func TestDeleteOldSystemStats(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Test data for different record types and their retention periods
|
||||
testCases := []struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
shouldBeKept bool
|
||||
ageFromNow time.Duration
|
||||
description string
|
||||
}{
|
||||
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||
}
|
||||
|
||||
// Create test records for both system_stats and container_stats
|
||||
collections := []string{"system_stats", "container_stats"}
|
||||
recordIds := make(map[string][]string)
|
||||
|
||||
for _, collection := range collections {
|
||||
recordIds[collection] = make([]string, 0)
|
||||
|
||||
for i, tc := range testCases {
|
||||
recordTime := now.Add(-tc.ageFromNow)
|
||||
|
||||
var stats string
|
||||
if collection == "system_stats" {
|
||||
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||
} else {
|
||||
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||
}
|
||||
|
||||
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||
"system": system.Id,
|
||||
"type": tc.recordType,
|
||||
"stats": stats,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldSystemStats(hub)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify results
|
||||
for _, collection := range collections {
|
||||
for i, tc := range testCases {
|
||||
recordId := recordIds[collection][i]
|
||||
|
||||
// Try to find the record
|
||||
_, err := hub.FindRecordById(collection, recordId)
|
||||
|
||||
if tc.shouldBeKept {
|
||||
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||
} else {
|
||||
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test users
|
||||
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
now := time.Now().UTC()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *core.Record
|
||||
alertCount int
|
||||
countToKeep int
|
||||
countBeforeDeletion int
|
||||
expectedAfterDeletion int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "User with few alerts (below threshold)",
|
||||
user: user1,
|
||||
alertCount: 100,
|
||||
countToKeep: 50,
|
||||
countBeforeDeletion: 150,
|
||||
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||
},
|
||||
{
|
||||
name: "User with many alerts (above threshold)",
|
||||
user: user2,
|
||||
alertCount: 300,
|
||||
countToKeep: 100,
|
||||
countBeforeDeletion: 200,
|
||||
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create alerts for this user
|
||||
for i := 0; i < tc.alertCount; i++ {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": tc.user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count before deletion
|
||||
countBefore, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count after deletion
|
||||
countAfter, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||
|
||||
// If deletion occurred, verify the most recent records were kept
|
||||
if tc.expectedAfterDeletion < tc.alertCount {
|
||||
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||
"user = {:user}",
|
||||
"-created", // Order by created DESC
|
||||
tc.countToKeep,
|
||||
0,
|
||||
map[string]any{"user": tc.user.Id})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||
|
||||
// Verify records are in descending order by created time
|
||||
for i := 1; i < len(records); i++ {
|
||||
prev := records[i-1].GetDateTime("created").Time()
|
||||
curr := records[i].GetDateTime("created").Time()
|
||||
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||
"Records should be ordered by created time (newest first)")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||
// Create user with few alerts
|
||||
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
|
||||
// Create only 5 alerts (well below threshold)
|
||||
for i := range 5 {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should not error and should not delete anything
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||
})
|
||||
|
||||
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||
// Clear any existing alerts
|
||||
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not error with empty table
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
||||
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user and system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create old systemd service records that should be deleted (older than 20 minutes)
|
||||
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "nginx.service",
|
||||
"state": 0, // Active
|
||||
"sub": 1, // Running
|
||||
"cpu": 5.0,
|
||||
"cpuPeak": 10.0,
|
||||
"memory": 1024000,
|
||||
"memPeak": 2048000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 25 minutes ago (should be deleted)
|
||||
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(oldRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create recent systemd service record that should be kept (within 20 minutes)
|
||||
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "apache.service",
|
||||
"state": 1, // Inactive
|
||||
"sub": 0, // Dead
|
||||
"cpu": 2.0,
|
||||
"cpuPeak": 3.0,
|
||||
"memory": 512000,
|
||||
"memPeak": 1024000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 10 minutes ago (should be kept)
|
||||
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(recentRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count records before deletion
|
||||
countBefore, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
||||
|
||||
// Run deletion via RecordManager
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
countAfter, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
||||
|
||||
// Verify the correct record was kept
|
||||
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
||||
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
||||
}
|
||||
@@ -3,430 +3,15 @@
|
||||
package records_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/records"
|
||||
"github.com/henrygd/beszel/internal/tests"
|
||||
|
||||
"github.com/pocketbase/dbx"
|
||||
"github.com/pocketbase/pocketbase/core"
|
||||
"github.com/pocketbase/pocketbase/tools/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDeleteOldRecords tests the main DeleteOldRecords function
|
||||
func TestDeleteOldRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user for alerts history
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create test system
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Create old system_stats records that should be deleted
|
||||
var record *core.Record
|
||||
record, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 50.0, "mem": 1024}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// created is autodate field, so we need to set it manually
|
||||
record.SetRaw("created", now.UTC().Add(-2*time.Hour).Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, record)
|
||||
require.InDelta(t, record.GetDateTime("created").Time().UTC().Unix(), now.UTC().Add(-2*time.Hour).Unix(), 1)
|
||||
require.Equal(t, record.Get("system"), system.Id)
|
||||
require.Equal(t, record.Get("type"), "1m")
|
||||
|
||||
// Create recent system_stats record that should be kept
|
||||
_, err = tests.CreateRecord(hub, "system_stats", map[string]any{
|
||||
"system": system.Id,
|
||||
"type": "1m",
|
||||
"stats": `{"cpu": 30.0, "mem": 512}`,
|
||||
"created": now.Add(-30 * time.Minute), // 30 minutes old, should be kept
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create many alerts history records to trigger deletion
|
||||
for i := range 260 { // More than countBeforeDeletion (250)
|
||||
_, err = tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count records before deletion
|
||||
systemStatsCountBefore, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountBefore, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run deletion
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
systemStatsCountAfter, err := hub.CountRecords("system_stats")
|
||||
require.NoError(t, err)
|
||||
alertsCountAfter, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify old system stats were deleted
|
||||
assert.Less(t, systemStatsCountAfter, systemStatsCountBefore, "Old system stats should be deleted")
|
||||
|
||||
// Verify alerts history was trimmed
|
||||
assert.Less(t, alertsCountAfter, alertsCountBefore, "Excessive alerts history should be deleted")
|
||||
assert.Equal(t, alertsCountAfter, int64(200), "Alerts count should be equal to countToKeep (200)")
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemStats tests the deleteOldSystemStats function
|
||||
func TestDeleteOldSystemStats(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Test data for different record types and their retention periods
|
||||
testCases := []struct {
|
||||
recordType string
|
||||
retention time.Duration
|
||||
shouldBeKept bool
|
||||
ageFromNow time.Duration
|
||||
description string
|
||||
}{
|
||||
{"1m", time.Hour, true, 30 * time.Minute, "1m record within 1 hour should be kept"},
|
||||
{"1m", time.Hour, false, 2 * time.Hour, "1m record older than 1 hour should be deleted"},
|
||||
{"10m", 12 * time.Hour, true, 6 * time.Hour, "10m record within 12 hours should be kept"},
|
||||
{"10m", 12 * time.Hour, false, 24 * time.Hour, "10m record older than 12 hours should be deleted"},
|
||||
{"20m", 24 * time.Hour, true, 12 * time.Hour, "20m record within 24 hours should be kept"},
|
||||
{"20m", 24 * time.Hour, false, 48 * time.Hour, "20m record older than 24 hours should be deleted"},
|
||||
{"120m", 7 * 24 * time.Hour, true, 3 * 24 * time.Hour, "120m record within 7 days should be kept"},
|
||||
{"120m", 7 * 24 * time.Hour, false, 10 * 24 * time.Hour, "120m record older than 7 days should be deleted"},
|
||||
{"480m", 30 * 24 * time.Hour, true, 15 * 24 * time.Hour, "480m record within 30 days should be kept"},
|
||||
{"480m", 30 * 24 * time.Hour, false, 45 * 24 * time.Hour, "480m record older than 30 days should be deleted"},
|
||||
}
|
||||
|
||||
// Create test records for both system_stats and container_stats
|
||||
collections := []string{"system_stats", "container_stats"}
|
||||
recordIds := make(map[string][]string)
|
||||
|
||||
for _, collection := range collections {
|
||||
recordIds[collection] = make([]string, 0)
|
||||
|
||||
for i, tc := range testCases {
|
||||
recordTime := now.Add(-tc.ageFromNow)
|
||||
|
||||
var stats string
|
||||
if collection == "system_stats" {
|
||||
stats = fmt.Sprintf(`{"cpu": %d.0, "mem": %d}`, i*10, i*100)
|
||||
} else {
|
||||
stats = fmt.Sprintf(`[{"name": "container%d", "cpu": %d.0, "mem": %d}]`, i, i*5, i*50)
|
||||
}
|
||||
|
||||
record, err := tests.CreateRecord(hub, collection, map[string]any{
|
||||
"system": system.Id,
|
||||
"type": tc.recordType,
|
||||
"stats": stats,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
record.SetRaw("created", recordTime.Format(types.DefaultDateLayout))
|
||||
err = hub.SaveNoValidate(record)
|
||||
require.NoError(t, err)
|
||||
recordIds[collection] = append(recordIds[collection], record.Id)
|
||||
}
|
||||
}
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldSystemStats(hub)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify results
|
||||
for _, collection := range collections {
|
||||
for i, tc := range testCases {
|
||||
recordId := recordIds[collection][i]
|
||||
|
||||
// Try to find the record
|
||||
_, err := hub.FindRecordById(collection, recordId)
|
||||
|
||||
if tc.shouldBeKept {
|
||||
assert.NoError(t, err, "Record should exist: %s", tc.description)
|
||||
} else {
|
||||
assert.Error(t, err, "Record should be deleted: %s", tc.description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistory tests the deleteOldAlertsHistory function
|
||||
func TestDeleteOldAlertsHistory(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
// Create test users
|
||||
user1, err := tests.CreateUser(hub, "user1@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
user2, err := tests.CreateUser(hub, "user2@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user1.Id, user2.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
now := time.Now().UTC()
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
user *core.Record
|
||||
alertCount int
|
||||
countToKeep int
|
||||
countBeforeDeletion int
|
||||
expectedAfterDeletion int
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "User with few alerts (below threshold)",
|
||||
user: user1,
|
||||
alertCount: 100,
|
||||
countToKeep: 50,
|
||||
countBeforeDeletion: 150,
|
||||
expectedAfterDeletion: 100, // No deletion because below threshold
|
||||
description: "User with alerts below countBeforeDeletion should not have any deleted",
|
||||
},
|
||||
{
|
||||
name: "User with many alerts (above threshold)",
|
||||
user: user2,
|
||||
alertCount: 300,
|
||||
countToKeep: 100,
|
||||
countBeforeDeletion: 200,
|
||||
expectedAfterDeletion: 100, // Should be trimmed to countToKeep
|
||||
description: "User with alerts above countBeforeDeletion should be trimmed to countToKeep",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Create alerts for this user
|
||||
for i := 0; i < tc.alertCount; i++ {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": tc.user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
"created": now.Add(-time.Duration(i) * time.Minute),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Count before deletion
|
||||
countBefore, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(tc.alertCount), countBefore, "Initial count should match")
|
||||
|
||||
// Run deletion
|
||||
err = records.DeleteOldAlertsHistory(hub, tc.countToKeep, tc.countBeforeDeletion)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count after deletion
|
||||
countAfter, err := hub.CountRecords("alerts_history",
|
||||
dbx.NewExp("user = {:user}", dbx.Params{"user": tc.user.Id}))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, int64(tc.expectedAfterDeletion), countAfter, tc.description)
|
||||
|
||||
// If deletion occurred, verify the most recent records were kept
|
||||
if tc.expectedAfterDeletion < tc.alertCount {
|
||||
records, err := hub.FindRecordsByFilter("alerts_history",
|
||||
"user = {:user}",
|
||||
"-created", // Order by created DESC
|
||||
tc.countToKeep,
|
||||
0,
|
||||
map[string]any{"user": tc.user.Id})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, records, tc.expectedAfterDeletion, "Should have exactly countToKeep records")
|
||||
|
||||
// Verify records are in descending order by created time
|
||||
for i := 1; i < len(records); i++ {
|
||||
prev := records[i-1].GetDateTime("created").Time()
|
||||
curr := records[i].GetDateTime("created").Time()
|
||||
assert.True(t, prev.After(curr) || prev.Equal(curr),
|
||||
"Records should be ordered by created time (newest first)")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteOldAlertsHistoryEdgeCases tests edge cases for alerts history deletion
|
||||
func TestDeleteOldAlertsHistoryEdgeCases(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
t.Run("No users with excessive alerts", func(t *testing.T) {
|
||||
// Create user with few alerts
|
||||
user, err := tests.CreateUser(hub, "few@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
|
||||
// Create only 5 alerts (well below threshold)
|
||||
for i := range 5 {
|
||||
_, err := tests.CreateRecord(hub, "alerts_history", map[string]any{
|
||||
"user": user.Id,
|
||||
"name": "CPU",
|
||||
"value": i + 1,
|
||||
"system": system.Id,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Should not error and should not delete anything
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
|
||||
count, err := hub.CountRecords("alerts_history")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(5), count, "All alerts should remain")
|
||||
})
|
||||
|
||||
t.Run("Empty alerts_history table", func(t *testing.T) {
|
||||
// Clear any existing alerts
|
||||
_, err := hub.DB().NewQuery("DELETE FROM alerts_history").Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Should not error with empty table
|
||||
err = records.DeleteOldAlertsHistory(hub, 10, 20)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeleteOldSystemdServiceRecords tests systemd service cleanup via DeleteOldRecords
|
||||
func TestDeleteOldSystemdServiceRecords(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
defer hub.Cleanup()
|
||||
|
||||
rm := records.NewRecordManager(hub)
|
||||
|
||||
// Create test user and system
|
||||
user, err := tests.CreateUser(hub, "test@example.com", "testtesttest")
|
||||
require.NoError(t, err)
|
||||
|
||||
system, err := tests.CreateRecord(hub, "systems", map[string]any{
|
||||
"name": "test-system",
|
||||
"host": "localhost",
|
||||
"port": "45876",
|
||||
"status": "up",
|
||||
"users": []string{user.Id},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Create old systemd service records that should be deleted (older than 20 minutes)
|
||||
oldRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "nginx.service",
|
||||
"state": 0, // Active
|
||||
"sub": 1, // Running
|
||||
"cpu": 5.0,
|
||||
"cpuPeak": 10.0,
|
||||
"memory": 1024000,
|
||||
"memPeak": 2048000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 25 minutes ago (should be deleted)
|
||||
oldRecord.SetRaw("updated", now.Add(-25*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(oldRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create recent systemd service record that should be kept (within 20 minutes)
|
||||
recentRecord, err := tests.CreateRecord(hub, "systemd_services", map[string]any{
|
||||
"system": system.Id,
|
||||
"name": "apache.service",
|
||||
"state": 1, // Inactive
|
||||
"sub": 0, // Dead
|
||||
"cpu": 2.0,
|
||||
"cpuPeak": 3.0,
|
||||
"memory": 512000,
|
||||
"memPeak": 1024000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
// Set updated time to 10 minutes ago (should be kept)
|
||||
recentRecord.SetRaw("updated", now.Add(-10*time.Minute).UnixMilli())
|
||||
err = hub.SaveNoValidate(recentRecord)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Count records before deletion
|
||||
countBefore, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), countBefore, "Should have 2 systemd service records initially")
|
||||
|
||||
// Run deletion via RecordManager
|
||||
rm.DeleteOldRecords()
|
||||
|
||||
// Count records after deletion
|
||||
countAfter, err := hub.CountRecords("systemd_services")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), countAfter, "Should have 1 systemd service record after deletion")
|
||||
|
||||
// Verify the correct record was kept
|
||||
remainingRecords, err := hub.FindRecordsByFilter("systemd_services", "", "", 10, 0, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remainingRecords, 1, "Should have exactly 1 record remaining")
|
||||
assert.Equal(t, "apache.service", remainingRecords[0].Get("name"), "The recent record should be kept")
|
||||
}
|
||||
|
||||
// TestRecordManagerCreation tests RecordManager creation
|
||||
func TestRecordManagerCreation(t *testing.T) {
|
||||
hub, err := tests.NewTestHub(t.TempDir())
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"recharts": "^2.15.4",
|
||||
"shiki": "^3.13.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"valibot": "^0.42.1",
|
||||
"valibot": "^1.3.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.4",
|
||||
@@ -927,7 +927,7 @@
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"valibot": ["valibot@0.42.1", "", { "peerDependencies": { "typescript": ">=5" } }, "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw=="],
|
||||
"valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="],
|
||||
|
||||
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||
|
||||
|
||||
@@ -22,11 +22,7 @@
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
globalThis.BESZEL = {
|
||||
BASE_PATH: "%BASE_URL%",
|
||||
HUB_VERSION: "{{V}}",
|
||||
HUB_URL: "{{HUB_URL}}"
|
||||
}
|
||||
globalThis.BESZEL = "{info}"
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
407
internal/site/package-lock.json
generated
407
internal/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "beszel",
|
||||
"version": "0.18.3",
|
||||
"version": "0.18.7",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "beszel",
|
||||
"version": "0.18.3",
|
||||
"version": "0.18.7",
|
||||
"dependencies": {
|
||||
"@henrygd/queue": "^1.0.7",
|
||||
"@henrygd/semaphore": "^0.0.2",
|
||||
@@ -44,7 +44,7 @@
|
||||
"recharts": "^2.15.4",
|
||||
"shiki": "^3.13.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"valibot": "^0.42.1"
|
||||
"valibot": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.4",
|
||||
@@ -986,29 +986,6 @@
|
||||
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/balanced-match": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
|
||||
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@isaacs/balanced-match": "^4.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -1243,9 +1220,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@lingui/cli/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2408,9 +2385,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.1.tgz",
|
||||
"integrity": "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2422,9 +2399,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.1.tgz",
|
||||
"integrity": "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2436,9 +2413,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.1.tgz",
|
||||
"integrity": "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2450,9 +2427,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.1.tgz",
|
||||
"integrity": "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2464,9 +2441,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.1.tgz",
|
||||
"integrity": "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2478,9 +2455,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.1.tgz",
|
||||
"integrity": "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2492,9 +2469,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.1.tgz",
|
||||
"integrity": "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2506,9 +2483,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.1.tgz",
|
||||
"integrity": "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -2520,9 +2497,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.1.tgz",
|
||||
"integrity": "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2534,9 +2511,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.1.tgz",
|
||||
"integrity": "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2547,10 +2524,24 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.1.tgz",
|
||||
"integrity": "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==",
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -2562,9 +2553,23 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.1.tgz",
|
||||
"integrity": "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2576,9 +2581,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.1.tgz",
|
||||
"integrity": "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2590,9 +2595,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.1.tgz",
|
||||
"integrity": "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -2604,9 +2609,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.1.tgz",
|
||||
"integrity": "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2618,9 +2623,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.1.tgz",
|
||||
"integrity": "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2632,9 +2637,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.1.tgz",
|
||||
"integrity": "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2645,10 +2650,38 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
|
||||
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
|
||||
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.1.tgz",
|
||||
"integrity": "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2660,9 +2693,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.1.tgz",
|
||||
"integrity": "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -2673,10 +2706,24 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
|
||||
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.1.tgz",
|
||||
"integrity": "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -3235,6 +3282,66 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.4.5",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.0.4",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.4.5",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.0.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
||||
@@ -3589,9 +3696,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3620,6 +3727,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -3666,6 +3783,19 @@
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
@@ -5072,9 +5202,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.sortby": {
|
||||
@@ -5267,9 +5397,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/micromatch/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5290,16 +5420,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.1.1",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"@isaacs/brace-expansion": "^5.0.0"
|
||||
"brace-expansion": "^5.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
"node": "18 || 20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -5575,9 +5705,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -5956,9 +6086,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.48.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.1.tgz",
|
||||
"integrity": "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==",
|
||||
"version": "4.60.1",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5972,26 +6102,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.48.1",
|
||||
"@rollup/rollup-android-arm64": "4.48.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.48.1",
|
||||
"@rollup/rollup-darwin-x64": "4.48.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.48.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.48.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.48.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.48.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.48.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.48.1",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.48.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.48.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.48.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.48.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.48.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.48.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.48.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.48.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.48.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.48.1",
|
||||
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||
"@rollup/rollup-android-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||
"@rollup/rollup-openbsd-x64": "4.60.1",
|
||||
"@rollup/rollup-openharmony-arm64": "4.60.1",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.60.1",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.60.1",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
@@ -6290,9 +6425,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.7",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
||||
"version": "7.5.13",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -6559,9 +6694,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/valibot": {
|
||||
"version": "0.42.1",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-0.42.1.tgz",
|
||||
"integrity": "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz",
|
||||
"integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "beszel",
|
||||
"private": true,
|
||||
"version": "0.18.6",
|
||||
"version": "0.18.7",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host",
|
||||
@@ -52,7 +52,7 @@
|
||||
"recharts": "^2.15.4",
|
||||
"shiki": "^3.13.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"valibot": "^0.42.1"
|
||||
"valibot": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.2.4",
|
||||
|
||||
@@ -20,7 +20,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||
<BellIcon
|
||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
||||
className={cn("size-[1.2em] pointer-events-none", {
|
||||
"fill-primary": hasSystemAlert,
|
||||
})}
|
||||
/>
|
||||
|
||||
@@ -2,11 +2,13 @@ import { t } from "@lingui/core/macro"
|
||||
import { Plural, Trans } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { getPagePath } from "@nanostores/router"
|
||||
import { GlobeIcon, ServerIcon } from "lucide-react"
|
||||
import { ChevronDownIcon, GlobeIcon, ServerIcon } from "lucide-react"
|
||||
import { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||
import { $router, Link } from "@/components/router"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
@@ -64,11 +66,57 @@ const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems:
|
||||
|
||||
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
|
||||
const alerts = useStore($alerts)
|
||||
const systems = useStore($systems)
|
||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||
const [currentTab, setCurrentTab] = useState("system")
|
||||
// copyKey is used to force remount AlertContent components with
|
||||
// new alert data after copying alerts from another system
|
||||
const [copyKey, setCopyKey] = useState(0)
|
||||
|
||||
const systemAlerts = alerts[system.id] ?? new Map()
|
||||
|
||||
// Systems that have at least one alert configured (excluding the current system)
|
||||
const systemsWithAlerts = useMemo(
|
||||
() => systems.filter((s) => s.id !== system.id && alerts[s.id]?.size),
|
||||
[systems, alerts, system.id]
|
||||
)
|
||||
|
||||
async function copyAlertsFromSystem(sourceSystemId: string) {
|
||||
const sourceAlerts = $alerts.get()[sourceSystemId]
|
||||
if (!sourceAlerts?.size) return
|
||||
try {
|
||||
const currentTargetAlerts = $alerts.get()[system.id] ?? new Map()
|
||||
// Alert names present on target but absent from source should be deleted
|
||||
const namesToDelete = Array.from(currentTargetAlerts.keys()).filter((name) => !sourceAlerts.has(name))
|
||||
await Promise.all([
|
||||
...Array.from(sourceAlerts.values()).map(({ name, value, min }) =>
|
||||
pb.send<{ success: boolean }>(endpoint, {
|
||||
method: "POST",
|
||||
body: { name, value, min, systems: [system.id], overwrite: true },
|
||||
requestKey: name,
|
||||
})
|
||||
),
|
||||
...namesToDelete.map((name) =>
|
||||
pb.send<{ success: boolean }>(endpoint, {
|
||||
method: "DELETE",
|
||||
body: { name, systems: [system.id] },
|
||||
requestKey: name,
|
||||
})
|
||||
),
|
||||
])
|
||||
// Optimistically update the store so components re-mount with correct data
|
||||
// before the realtime subscription event arrives.
|
||||
const newSystemAlerts = new Map<string, AlertRecord>()
|
||||
for (const alert of sourceAlerts.values()) {
|
||||
newSystemAlerts.set(alert.name, { ...alert, system: system.id, triggered: false })
|
||||
}
|
||||
$alerts.setKey(system.id, newSystemAlerts)
|
||||
setCopyKey((k) => k + 1)
|
||||
} catch (error) {
|
||||
failedUpdateToast(error)
|
||||
}
|
||||
}
|
||||
|
||||
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
|
||||
// current alerts, it will only be updated when first checked, then won't be updated because
|
||||
// after that it exists.
|
||||
@@ -93,18 +141,37 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
|
||||
<TabsList className="mb-1 -mt-0.5">
|
||||
<TabsTrigger value="system">
|
||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||
<span className="truncate max-w-60">{system.name}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="global">
|
||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||
<Trans>All Systems</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex items-center justify-between mb-1 -mt-0.5">
|
||||
<TabsList>
|
||||
<TabsTrigger value="system">
|
||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||
<span className="truncate max-w-60">{system.name}</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="global">
|
||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||
<Trans>All Systems</Trans>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{systemsWithAlerts.length > 0 && currentTab === "system" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-muted-foreground text-xs gap-1.5">
|
||||
<Trans context="Copy alerts from another system">Copy from</Trans>
|
||||
<ChevronDownIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="max-h-100 overflow-auto">
|
||||
{systemsWithAlerts.map((s) => (
|
||||
<DropdownMenuItem key={s.id} className="min-w-44" onSelect={() => copyAlertsFromSystem(s.id)}>
|
||||
{s.name}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="system">
|
||||
<div className="grid gap-3">
|
||||
<div key={copyKey} className="grid gap-3">
|
||||
{alertKeys.map((name) => (
|
||||
<AlertContent
|
||||
key={name}
|
||||
|
||||
@@ -41,8 +41,8 @@ export default function AreaChartDefault({
|
||||
hideYAxis = false,
|
||||
filter,
|
||||
truncate = false,
|
||||
}: // logRender = false,
|
||||
{
|
||||
chartProps,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||
customData?: any[]
|
||||
@@ -62,7 +62,7 @@ export default function AreaChartDefault({
|
||||
hideYAxis?: boolean
|
||||
filter?: string
|
||||
truncate?: boolean
|
||||
// logRender?: boolean
|
||||
chartProps?: Omit<React.ComponentProps<typeof AreaChart>, "data" | "margin">
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
@@ -131,6 +131,7 @@ export default function AreaChartDefault({
|
||||
accessibilityLayer
|
||||
data={displayData}
|
||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||
{...chartProps}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{!hideYAxis && (
|
||||
|
||||
@@ -40,8 +40,9 @@ export default function LineChartDefault({
|
||||
hideYAxis = false,
|
||||
filter,
|
||||
truncate = false,
|
||||
}: // logRender = false,
|
||||
{
|
||||
chartProps,
|
||||
connectNulls,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||
customData?: any[]
|
||||
@@ -61,7 +62,8 @@ export default function LineChartDefault({
|
||||
hideYAxis?: boolean
|
||||
filter?: string
|
||||
truncate?: boolean
|
||||
// logRender?: boolean
|
||||
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||
connectNulls?: boolean
|
||||
}) {
|
||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||
@@ -104,7 +106,8 @@ export default function LineChartDefault({
|
||||
isAnimationActive={false}
|
||||
// stackId={dataPoint.stackId}
|
||||
order={dataPoint.order || i}
|
||||
// activeDot={dataPoint.activeDot ?? true}
|
||||
activeDot={dataPoint.activeDot ?? true}
|
||||
connectNulls={connectNulls}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -130,6 +133,7 @@ export default function LineChartDefault({
|
||||
accessibilityLayer
|
||||
data={displayData}
|
||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||
{...chartProps}
|
||||
>
|
||||
<CartesianGrid vertical={false} />
|
||||
{!hideYAxis && (
|
||||
|
||||
@@ -12,7 +12,7 @@ import { Label } from "@/components/ui/label"
|
||||
import { pb } from "@/lib/api"
|
||||
import { $authenticated } from "@/lib/stores"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { $router, Link, prependBasePath } from "../router"
|
||||
import { $router, Link, basePath, prependBasePath } from "../router"
|
||||
import { toast } from "../ui/use-toast"
|
||||
import { OtpInputForm } from "./otp-forms"
|
||||
|
||||
@@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({
|
||||
passwordConfirm: passwordSchema,
|
||||
})
|
||||
|
||||
export const showLoginFaliedToast = (description?: string) => {
|
||||
description ||= t`Please check your credentials and try again`
|
||||
export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
|
||||
toast({
|
||||
title: t`Login attempt failed`,
|
||||
description,
|
||||
@@ -130,10 +129,6 @@ export function UserAuthForm({
|
||||
[isFirstRun]
|
||||
)
|
||||
|
||||
if (!authMethods) {
|
||||
return null
|
||||
}
|
||||
|
||||
const authProviders = authMethods.oauth2.providers ?? []
|
||||
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
||||
const passwordEnabled = authMethods.password.enabled
|
||||
@@ -142,6 +137,12 @@ export function UserAuthForm({
|
||||
|
||||
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
||||
setIsOauthLoading(true)
|
||||
|
||||
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
|
||||
redirectToOauthProvider(provider)
|
||||
return
|
||||
}
|
||||
|
||||
const oAuthOpts: OAuth2AuthConfig = {
|
||||
provider: provider.name,
|
||||
}
|
||||
@@ -150,10 +151,7 @@ export function UserAuthForm({
|
||||
const authWindow = window.open()
|
||||
if (!authWindow) {
|
||||
setIsOauthLoading(false)
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: t`Please enable pop-ups for this site`,
|
||||
})
|
||||
showLoginFaliedToast(t`Please enable pop-ups for this site`)
|
||||
return
|
||||
}
|
||||
oAuthOpts.urlCallback = (url) => {
|
||||
@@ -171,16 +169,57 @@ export function UserAuthForm({
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects the user to the OAuth provider's authentication page in the same window.
|
||||
* Requires the app's base URL to be registered as a redirect URI with the OAuth provider.
|
||||
*/
|
||||
function redirectToOauthProvider(provider: AuthProviderInfo) {
|
||||
const url = new URL(provider.authURL)
|
||||
// url.searchParams.set("redirect_uri", `${window.location.origin}${basePath}`)
|
||||
sessionStorage.setItem("provider", JSON.stringify(provider))
|
||||
window.location.href = url.toString()
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// auto login if password disabled and only one auth provider
|
||||
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
||||
// Add a small timeout to ensure browser is ready to handle popups
|
||||
setTimeout(() => {
|
||||
loginWithOauth(authProviders[0], true)
|
||||
}, 300)
|
||||
// handle redirect-based OAuth callback if we have a code
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const code = params.get("code")
|
||||
if (code) {
|
||||
const state = params.get("state")
|
||||
const provider: AuthProviderInfo = JSON.parse(sessionStorage.getItem("provider") ?? "{}")
|
||||
if (!state || provider.state !== state) {
|
||||
showLoginFaliedToast()
|
||||
} else {
|
||||
setIsOauthLoading(true)
|
||||
window.history.replaceState({}, "", window.location.pathname)
|
||||
pb.collection("users")
|
||||
.authWithOAuth2Code(provider.name, code, provider.codeVerifier, `${window.location.origin}${basePath}`)
|
||||
.then(() => $authenticated.set(pb.authStore.isValid))
|
||||
.catch((e: unknown) => showLoginFaliedToast((e as Error).message))
|
||||
.finally(() => setIsOauthLoading(false))
|
||||
}
|
||||
}
|
||||
|
||||
// auto login if password disabled and only one auth provider
|
||||
if (!code && !passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
||||
// Add a small timeout to ensure browser is ready to handle popups
|
||||
setTimeout(() => loginWithOauth(authProviders[0], false), 300)
|
||||
return
|
||||
}
|
||||
|
||||
// refresh auth if not in above states (required for trusted auth header)
|
||||
pb.collection("users")
|
||||
.authRefresh()
|
||||
.then((res) => {
|
||||
pb.authStore.save(res.token, res.record)
|
||||
$authenticated.set(!!pb.authStore.isValid)
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (!authMethods) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (otpId && mfaId) {
|
||||
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
||||
}
|
||||
@@ -248,7 +287,7 @@ export function UserAuthForm({
|
||||
)}
|
||||
<div className="sr-only">
|
||||
{/* honeypot */}
|
||||
<label htmlFor="website"></label>
|
||||
<label htmlFor="website">Website</label>
|
||||
<input
|
||||
id="website"
|
||||
type="text"
|
||||
|
||||
@@ -1,28 +1,39 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { MoonStarIcon, SunIcon } from "lucide-react"
|
||||
import { MoonStarIcon, SunIcon, SunMoonIcon } from "lucide-react"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const themes = ["light", "dark", "system"] as const
|
||||
const icons = [SunIcon, MoonStarIcon, SunMoonIcon] as const
|
||||
|
||||
export function ModeToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
|
||||
const currentIndex = themes.indexOf(theme)
|
||||
const Icon = icons[currentIndex]
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={"ghost"}
|
||||
size="icon"
|
||||
aria-label={t`Toggle theme`}
|
||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
||||
aria-label={t`Switch theme`}
|
||||
onClick={() => setTheme(themes[(currentIndex + 1) % themes.length])}
|
||||
>
|
||||
<SunIcon className="h-[1.2rem] w-[1.2rem] transition-all -rotate-90 dark:opacity-0 dark:rotate-0" />
|
||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
|
||||
<Icon
|
||||
className={cn(
|
||||
"animate-in fade-in spin-in-[-30deg] duration-200",
|
||||
currentIndex === 2 ? "size-[1.35rem]" : "size-[1.2rem]"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Toggle theme</Trans>
|
||||
<Trans>Switch theme</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
LogOutIcon,
|
||||
LogsIcon,
|
||||
MenuIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
SearchIcon,
|
||||
ServerIcon,
|
||||
@@ -63,7 +64,7 @@ export default function Navbar() {
|
||||
className="p-2 ps-0 me-3 group"
|
||||
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
||||
>
|
||||
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
|
||||
<Logo className="h-[1.2rem] md:h-5 fill-foreground" />
|
||||
</Link>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -109,6 +110,10 @@ export default function Navbar() {
|
||||
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<span>S.M.A.R.T.</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "probes"))} className="flex items-center">
|
||||
<NetworkIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
|
||||
<Trans>Network Probes</Trans>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
|
||||
className="flex items-center"
|
||||
@@ -125,15 +130,17 @@ export default function Navbar() {
|
||||
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="flex items-center"
|
||||
onSelect={() => {
|
||||
setAddSystemDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</DropdownMenuItem>
|
||||
{!isReadOnlyUser() && (
|
||||
<DropdownMenuItem
|
||||
className="flex items-center"
|
||||
onSelect={() => {
|
||||
setAddSystemDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
@@ -178,6 +185,21 @@ export default function Navbar() {
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>S.M.A.R.T.</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
href={getPagePath($router, "probes")}
|
||||
className={cn("hidden md:grid", buttonVariants({ variant: "ghost", size: "icon" }))}
|
||||
aria-label="Network Probes"
|
||||
onMouseEnter={() => import("@/components/routes/probes")}
|
||||
>
|
||||
<NetworkIcon className="h-[1.2rem] w-[1.2rem]" strokeWidth={1.5} />
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<Trans>Network Probes</Trans>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<LangToggle />
|
||||
<ModeToggle />
|
||||
<Tooltip>
|
||||
@@ -217,10 +239,12 @@ export default function Navbar() {
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</Button>
|
||||
{!isReadOnlyUser() && (
|
||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, decimalString, hourWithSeconds } from "@/lib/utils"
|
||||
import {
|
||||
GlobeIcon,
|
||||
TimerIcon,
|
||||
ActivityIcon,
|
||||
WifiOffIcon,
|
||||
Trash2Icon,
|
||||
ArrowLeftRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
ServerIcon,
|
||||
ClockIcon,
|
||||
NetworkIcon,
|
||||
} from "lucide-react"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { pb } from "@/lib/api"
|
||||
import { toast } from "../ui/use-toast"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
const protocolColors: Record<string, string> = {
|
||||
icmp: "bg-blue-500/15 text-blue-400",
|
||||
tcp: "bg-purple-500/15 text-purple-400",
|
||||
http: "bg-green-500/15 text-green-400",
|
||||
}
|
||||
|
||||
async function deleteProbe(id: string) {
|
||||
try {
|
||||
await pb.collection("network_probes").delete(id)
|
||||
} catch (err: unknown) {
|
||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
||||
}
|
||||
}
|
||||
|
||||
export function getProbeColumns(longestName = 0, longestTarget = 0): ColumnDef<NetworkProbeRecord>[] {
|
||||
return [
|
||||
{
|
||||
id: "name",
|
||||
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
||||
accessorFn: (record) => record.name || record.target,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={NetworkIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "system",
|
||||
accessorFn: (record) => record.system,
|
||||
sortingFn: (a, b) => {
|
||||
const allSystems = $allSystemsById.get()
|
||||
const systemNameA = allSystems[a.original.system]?.name ?? ""
|
||||
const systemNameB = allSystems[b.original.system]?.name ?? ""
|
||||
return systemNameA.localeCompare(systemNameB)
|
||||
},
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`System`} Icon={ServerIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const allSystems = useStore($allSystemsById)
|
||||
return <span className="ms-1.5 xl:w-34 block truncate">{allSystems[getValue() as string]?.name ?? ""}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
|
||||
accessorFn: (record) => record.target,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5 tabular-nums block truncate max-w-44" style={{ width: `${longestTarget / 1.05}ch` }}>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
accessorFn: (record) => record.protocol,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const protocol = getValue() as string
|
||||
return (
|
||||
<span className={cn("ms-1.5 px-2 py-0.5 rounded text-xs font-medium uppercase", protocolColors[protocol])}>
|
||||
{protocol}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "interval",
|
||||
accessorFn: (record) => record.interval,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={TimerIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
|
||||
},
|
||||
{
|
||||
id: "latency",
|
||||
accessorFn: (record) => record.latency,
|
||||
// invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.latency
|
||||
if (val === undefined) {
|
||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||
}
|
||||
let color = "bg-green-500"
|
||||
if (!val || val > 200) {
|
||||
color = "bg-yellow-500"
|
||||
}
|
||||
if (val > 2000) {
|
||||
color = "bg-red-500"
|
||||
}
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||
{decimalString(val, val < 100 ? 2 : 1).toLocaleString()} ms
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "loss",
|
||||
accessorFn: (record) => record.loss,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Loss`} Icon={WifiOffIcon} />,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.loss
|
||||
if (val === undefined) {
|
||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||
}
|
||||
let color = "bg-green-500"
|
||||
if (val > 0) {
|
||||
color = val > 20 ? "bg-red-500" : "bg-yellow-500"
|
||||
}
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||
<span className={cn("shrink-0 size-2 rounded-full", color)} />
|
||||
{val}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated",
|
||||
invertSorting: true,
|
||||
accessorFn: (record) => record.updated,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Updated`} Icon={ClockIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const timestamp = getValue() as number
|
||||
return <span className="ms-1.5 tabular-nums">{hourWithSeconds(new Date(timestamp).toISOString())}</span>
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteProbe(row.original.id)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="me-2.5 size-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function HeaderButton({
|
||||
column,
|
||||
name,
|
||||
Icon,
|
||||
}: {
|
||||
column: Column<NetworkProbeRecord>
|
||||
name: string
|
||||
Icon: React.ElementType
|
||||
}) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
{/* <ArrowUpDownIcon className="size-4" /> */}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import {
|
||||
type ColumnFiltersState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getSortedRowModel,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Table as TableType,
|
||||
useReactTable,
|
||||
type VisibilityState,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import { memo, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { getProbeColumns } from "@/components/network-probes-table/network-probes-columns"
|
||||
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import { $allSystemsById } from "@/lib/stores"
|
||||
import { cn, getVisualStringWidth, useBrowserStorage } from "@/lib/utils"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
import { AddProbeDialog } from "./probe-dialog"
|
||||
|
||||
const NETWORK_PROBE_FIELDS = "id,name,system,target,protocol,port,interval,latency,loss,enabled,updated"
|
||||
|
||||
export default function NetworkProbesTableNew({
|
||||
systemId,
|
||||
probes,
|
||||
setProbes,
|
||||
}: {
|
||||
systemId?: string
|
||||
probes: NetworkProbeRecord[]
|
||||
setProbes: React.Dispatch<React.SetStateAction<NetworkProbeRecord[]>>
|
||||
}) {
|
||||
const [sorting, setSorting] = useBrowserStorage<SortingState>(
|
||||
`sort-np-${systemId ? 1 : 0}`,
|
||||
[{ id: systemId ? "name" : "system", desc: false }],
|
||||
sessionStorage
|
||||
)
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [globalFilter, setGlobalFilter] = useState("")
|
||||
|
||||
// clear old data when systemId changes
|
||||
useEffect(() => {
|
||||
return setProbes([])
|
||||
}, [systemId])
|
||||
|
||||
useEffect(() => {
|
||||
function fetchData(systemId?: string) {
|
||||
pb.collection<NetworkProbeRecord>("network_probes")
|
||||
.getList(0, 2000, {
|
||||
fields: NETWORK_PROBE_FIELDS,
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.then((res) => setProbes(res.items))
|
||||
}
|
||||
|
||||
// initial load
|
||||
fetchData(systemId)
|
||||
|
||||
// if no systemId, pull after every system update
|
||||
// if (!systemId) {
|
||||
// return $allSystemsById.listen((_value, _oldValue, systemId) => {
|
||||
// // exclude initial load of systems
|
||||
// if (Date.now() - loadTime > 500) {
|
||||
// fetchData(systemId)
|
||||
// }
|
||||
// })
|
||||
// }
|
||||
|
||||
// if systemId, fetch after the system is updated
|
||||
// return listenKeys($allSystemsById, [systemId], (_newSystems) => {
|
||||
// fetchData(systemId)
|
||||
// })
|
||||
}, [systemId])
|
||||
|
||||
// Subscribe to updates
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined
|
||||
const pbOptions = systemId
|
||||
? { fields: NETWORK_PROBE_FIELDS, filter: pb.filter("system = {:system}", { system: systemId }) }
|
||||
: { fields: NETWORK_PROBE_FIELDS }
|
||||
|
||||
;(async () => {
|
||||
try {
|
||||
unsubscribe = await pb.collection<NetworkProbeRecord>("network_probes").subscribe(
|
||||
"*",
|
||||
(event) => {
|
||||
const record = event.record
|
||||
setProbes((currentProbes) => {
|
||||
const probes = currentProbes ?? []
|
||||
const matchesSystemScope = !systemId || record.system === systemId
|
||||
|
||||
if (event.action === "delete") {
|
||||
return probes.filter((device) => device.id !== record.id)
|
||||
}
|
||||
|
||||
if (!matchesSystemScope) {
|
||||
// Record moved out of scope; ensure it disappears locally.
|
||||
return probes.filter((device) => device.id !== record.id)
|
||||
}
|
||||
|
||||
const existingIndex = probes.findIndex((device) => device.id === record.id)
|
||||
if (existingIndex === -1) {
|
||||
return [record, ...probes]
|
||||
}
|
||||
|
||||
const next = [...probes]
|
||||
next[existingIndex] = record
|
||||
return next
|
||||
})
|
||||
},
|
||||
pbOptions
|
||||
)
|
||||
} catch (error) {
|
||||
console.error("Failed to subscribe to SMART device updates:", error)
|
||||
}
|
||||
})()
|
||||
|
||||
return () => {
|
||||
unsubscribe?.()
|
||||
}
|
||||
}, [systemId])
|
||||
|
||||
const { longestName, longestTarget } = useMemo(() => {
|
||||
let longestName = 0
|
||||
let longestTarget = 0
|
||||
for (const p of probes) {
|
||||
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
|
||||
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
|
||||
}
|
||||
return { longestName, longestTarget }
|
||||
}, [probes])
|
||||
|
||||
// Filter columns based on whether systemId is provided
|
||||
const columns = useMemo(() => {
|
||||
let columns = getProbeColumns(longestName, longestTarget)
|
||||
columns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||
columns = isReadOnlyUser() ? columns.filter((col) => col.id !== "actions") : columns
|
||||
return columns
|
||||
}, [systemId, longestName, longestTarget])
|
||||
|
||||
const table = useReactTable({
|
||||
data: probes,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
defaultColumn: {
|
||||
sortUndefined: "last",
|
||||
size: 900,
|
||||
minSize: 0,
|
||||
},
|
||||
state: {
|
||||
sorting,
|
||||
columnFilters,
|
||||
columnVisibility,
|
||||
globalFilter,
|
||||
},
|
||||
onGlobalFilterChange: setGlobalFilter,
|
||||
globalFilterFn: (row, _columnId, filterValue) => {
|
||||
const probe = row.original
|
||||
const systemName = $allSystemsById.get()[probe.system]?.name ?? ""
|
||||
const searchString = `${probe.name}${probe.target}${probe.protocol}${systemName}`.toLocaleLowerCase()
|
||||
return (filterValue as string)
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.every((term) => searchString.includes(term))
|
||||
},
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
return (
|
||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle className="mb-2">
|
||||
<Trans>Network Probes</Trans>
|
||||
</CardTitle>
|
||||
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
|
||||
</div>
|
||||
</div>
|
||||
<div className="md:ms-auto flex items-center gap-2">
|
||||
{probes.length > 0 && (
|
||||
<Input
|
||||
placeholder={t`Filter...`}
|
||||
value={globalFilter}
|
||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||
className="ms-auto px-4 w-full max-w-full md:w-64"
|
||||
/>
|
||||
)}
|
||||
{!isReadOnlyUser() ? <AddProbeDialog systemId={systemId} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<div className="rounded-md">
|
||||
<NetworkProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkProbesTable = memo(function NetworkProbeTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
}: {
|
||||
table: TableType<NetworkProbeRecord>
|
||||
rows: Row<NetworkProbeRecord>[]
|
||||
colLength: number
|
||||
}) {
|
||||
// The virtualizer will need a reference to the scrollable container element
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 54,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] max-w-full relative overflow-auto border rounded-md",
|
||||
// don't set min height if there are less than 2 rows, do set if we need to display the empty state
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
{/* add header height to table size */}
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="text-sm w-full h-full text-nowrap">
|
||||
<NetworkProbeTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <NetworkProbeTableRow key={row.id} row={row} virtualRow={virtualRow} />
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
<Trans>No results.</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function NetworkProbeTableHead({ table }: { table: TableType<NetworkProbeRecord> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkProbeTableRow = memo(function NetworkProbeTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
}: {
|
||||
row: Row<NetworkProbeRecord>
|
||||
virtualRow: VirtualItem
|
||||
}) {
|
||||
return (
|
||||
<TableRow data-state={row.getIsSelected() && "selected"} className="transition-opacity">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="py-0"
|
||||
style={{
|
||||
width: `${cell.column.getSize()}px`,
|
||||
height: virtualRow.size,
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { useState } from "react"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { pb } from "@/lib/api"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||
import { PlusIcon } from "lucide-react"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { $systems } from "@/lib/stores"
|
||||
|
||||
export function AddProbeDialog({ systemId }: { systemId?: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [protocol, setProtocol] = useState<string>("icmp")
|
||||
const [target, setTarget] = useState("")
|
||||
const [port, setPort] = useState("")
|
||||
const [probeInterval, setProbeInterval] = useState("60")
|
||||
const [name, setName] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selectedSystemId, setSelectedSystemId] = useState("")
|
||||
const systems = useStore($systems)
|
||||
const { toast } = useToast()
|
||||
const { t } = useLingui()
|
||||
const targetName = target.replace(/^https?:\/\//, "")
|
||||
|
||||
const resetForm = () => {
|
||||
setProtocol("icmp")
|
||||
setTarget("")
|
||||
setPort("")
|
||||
setProbeInterval("60")
|
||||
setName("")
|
||||
setSelectedSystemId("")
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
await pb.collection("network_probes").create({
|
||||
system: systemId ?? selectedSystemId,
|
||||
name: name || targetName,
|
||||
target,
|
||||
protocol,
|
||||
port: protocol === "tcp" ? Number(port) : 0,
|
||||
interval: Number(probeInterval),
|
||||
enabled: true,
|
||||
})
|
||||
resetForm()
|
||||
setOpen(false)
|
||||
} catch (err: unknown) {
|
||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<PlusIcon className="size-4 me-1" />
|
||||
<Trans>Add {{ foo: t`Probe` }}</Trans>
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Add {{ foo: t`Network Probe` }}</Trans>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
<Trans>Configure ICMP, TCP, or HTTP latency monitoring from this agent.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="grid gap-4 tabular-nums">
|
||||
{!systemId && (
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>System</Trans>
|
||||
</Label>
|
||||
<Select value={selectedSystemId} onValueChange={setSelectedSystemId} required>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t`Select a system`} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{systems.map((sys) => (
|
||||
<SelectItem key={sys.id} value={sys.id}>
|
||||
{sys.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Target</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
value={target}
|
||||
onChange={(e) => setTarget(e.target.value)}
|
||||
placeholder={protocol === "http" ? "https://example.com" : "1.1.1.1"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Protocol</Trans>
|
||||
</Label>
|
||||
<Select value={protocol} onValueChange={setProtocol}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="icmp">ICMP</SelectItem>
|
||||
<SelectItem value="tcp">TCP</SelectItem>
|
||||
<SelectItem value="http">HTTP</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{protocol === "tcp" && (
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Port</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="443"
|
||||
min={1}
|
||||
max={65535}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Interval (seconds)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={probeInterval}
|
||||
onChange={(e) => setProbeInterval(e.target.value)}
|
||||
min={1}
|
||||
max={3600}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
<Trans>Name (optional)</Trans>
|
||||
</Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={targetName || t`e.g. Cloudflare DNS`}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={loading || (!systemId && !selectedSystemId)}>
|
||||
{loading ? <Trans>Creating...</Trans> : <Trans>Add {{ foo: t`Probe` }}</Trans>}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,7 @@ const routes = {
|
||||
home: "/",
|
||||
containers: "/containers",
|
||||
smart: "/smart",
|
||||
probes: "/probes",
|
||||
system: `/system/:id`,
|
||||
settings: `/settings/:name?`,
|
||||
forgot_password: `/forgot-password`,
|
||||
|
||||
25
internal/site/src/components/routes/probes.tsx
Normal file
25
internal/site/src/components/routes/probes.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { memo, useEffect, useState } from "react"
|
||||
import NetworkProbesTableNew from "@/components/network-probes-table/network-probes-table"
|
||||
import { ActiveAlerts } from "@/components/active-alerts"
|
||||
import { FooterRepoLink } from "@/components/footer-repo-link"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
|
||||
export default memo(() => {
|
||||
const { t } = useLingui()
|
||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
document.title = `${t`Network Probes`} / Beszel`
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid gap-4">
|
||||
<ActiveAlerts />
|
||||
<NetworkProbesTableNew probes={probes} setProbes={setProbes} />
|
||||
</div>
|
||||
<FooterRepoLink />
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -15,6 +15,7 @@ import { isAdmin, pb } from "@/lib/api"
|
||||
import type { UserSettings } from "@/types"
|
||||
import { saveSettings } from "./layout"
|
||||
import { QuietHours } from "./quiet-hours"
|
||||
import type { ClientResponseError } from "pocketbase"
|
||||
|
||||
interface ShoutrrrUrlCardProps {
|
||||
url: string
|
||||
@@ -59,10 +60,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
try {
|
||||
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
||||
await saveSettings(parsedData)
|
||||
} catch (e: any) {
|
||||
} catch (e: unknown) {
|
||||
toast({
|
||||
title: t`Failed to save settings`,
|
||||
description: e.message,
|
||||
description: (e as Error).message,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
@@ -136,12 +137,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 shrink-0"
|
||||
onClick={addWebhook}
|
||||
>
|
||||
<Button type="button" variant="outline" className="h-10 shrink-0" onClick={addWebhook}>
|
||||
<PlusIcon className="size-4" />
|
||||
<span className="ms-1">
|
||||
<Trans>Add URL</Trans>
|
||||
@@ -180,25 +176,34 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
||||
)
|
||||
}
|
||||
|
||||
function showTestNotificationError(msg: string) {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: msg ?? t`Failed to send test notification`,
|
||||
variant: "destructive",
|
||||
})
|
||||
}
|
||||
|
||||
const ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const sendTestNotification = async () => {
|
||||
setIsLoading(true)
|
||||
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t`Test notification sent`,
|
||||
description: t`Check your notification service`,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
title: t`Error`,
|
||||
description: res.err ?? t`Failed to send test notification`,
|
||||
variant: "destructive",
|
||||
})
|
||||
try {
|
||||
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||
if ("err" in res && !res.err) {
|
||||
toast({
|
||||
title: t`Test notification sent`,
|
||||
description: t`Check your notification service`,
|
||||
})
|
||||
} else {
|
||||
showTestNotificationError(res.err)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
showTestNotificationError((e as ClientResponseError).data?.message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { memo, useState } from "react"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
import { compareSemVer, parseSemVer } from "@/lib/utils"
|
||||
|
||||
import type { GPUData } from "@/types"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||
import InfoBar from "./system/info-bar"
|
||||
import { useSystemData } from "./system/use-system-data"
|
||||
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
|
||||
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-charts"
|
||||
import { DiskCharts } from "./system/charts/disk-charts"
|
||||
import { RootDiskCharts, ExtraFsCharts } from "./system/charts/disk-charts"
|
||||
import { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-charts"
|
||||
import { ExtraFsCharts } from "./system/charts/extra-fs-charts"
|
||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable } from "./system/lazy-tables"
|
||||
import { LazyContainersTable, LazySmartTable, LazySystemdTable, LazyNetworkProbesTableNew } from "./system/lazy-tables"
|
||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||
import { GpuIcon } from "../ui/icons"
|
||||
@@ -24,10 +22,13 @@ const SEMVER_0_14_0 = parseSemVer("0.14.0")
|
||||
const SEMVER_0_15_0 = parseSemVer("0.15.0")
|
||||
|
||||
export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
const systemData = useSystemData(id)
|
||||
|
||||
const {
|
||||
system,
|
||||
systemStats,
|
||||
containerData,
|
||||
probeStats,
|
||||
chartData,
|
||||
containerChartConfigs,
|
||||
details,
|
||||
@@ -48,7 +49,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
hasGpuData,
|
||||
hasGpuEnginesData,
|
||||
hasGpuPowerData,
|
||||
} = useSystemData(id)
|
||||
} = systemData
|
||||
|
||||
// extra margin to add to bottom of page, specifically for temperature chart,
|
||||
// where the tooltip can go past the bottom of the page if lots of sensors
|
||||
@@ -103,7 +104,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<DiskCharts {...coreProps} systemStats={systemStats} />
|
||||
<RootDiskCharts systemData={systemData} />
|
||||
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
|
||||
@@ -138,13 +139,17 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExtraFsCharts {...coreProps} systemStats={systemStats} />
|
||||
<ExtraFsCharts systemData={systemData} />
|
||||
|
||||
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
||||
|
||||
{hasContainersTable && <LazyContainersTable systemId={system.id} />}
|
||||
|
||||
{hasSystemd && <LazySystemdTable systemId={system.id} />}
|
||||
|
||||
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
|
||||
|
||||
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -188,18 +193,21 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||
<BatteryChart {...coreProps} />
|
||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||
</div>
|
||||
<LazyNetworkProbesTableNew systemId={system.id} systemData={systemData} />
|
||||
{/* <LazyNetworkProbesTable system={system} chartData={chartData} grid={grid} probeStats={probeStats} /> */}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="disk" forceMount className={activeTab === "disk" ? "contents" : "hidden"}>
|
||||
{mountedTabs.has("disk") && (
|
||||
<>
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
<DiskCharts {...coreProps} systemStats={systemStats} />
|
||||
<RootDiskCharts systemData={systemData} />
|
||||
</div>
|
||||
<ExtraFsCharts {...coreProps} systemStats={systemStats} />
|
||||
<ExtraFsCharts systemData={systemData} />
|
||||
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { timeTicks } from "d3-time"
|
||||
import { getPbTimestamp, pb } from "@/lib/api"
|
||||
import { chartTimeData } from "@/lib/utils"
|
||||
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
|
||||
import type { ChartData, ChartTimes, ContainerStatsRecord, NetworkProbeStatsRecord, SystemStatsRecord } from "@/types"
|
||||
|
||||
type ChartTimeData = {
|
||||
time: number
|
||||
@@ -66,12 +66,12 @@ export function appendData<T extends { created: string | number | null }>(
|
||||
return result
|
||||
}
|
||||
|
||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
|
||||
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord | NetworkProbeStatsRecord>(
|
||||
collection: string,
|
||||
systemId: string,
|
||||
chartTime: ChartTimes
|
||||
chartTime: ChartTimes,
|
||||
cachedStats?: { created: string | number | null }[]
|
||||
): Promise<T[]> {
|
||||
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
|
||||
const lastCached = cachedStats?.at(-1)?.created as number
|
||||
return await pb.collection<T>(collection).getFullList({
|
||||
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
|
||||
|
||||
@@ -1,106 +1,283 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import type { SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, SelectAvgMax } from "../chart-card"
|
||||
import { Unit } from "@/lib/enums"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
import DiskIoSheet from "../disk-io-sheet"
|
||||
import type { SystemData } from "../use-system-data"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
|
||||
export function DiskCharts({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
showMax,
|
||||
isLongerChart,
|
||||
maxValues,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
showMax: boolean
|
||||
isLongerChart: boolean
|
||||
maxValues: boolean
|
||||
systemStats: SystemStatsRecord[]
|
||||
}) {
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
const userSettings = $userSettings.get()
|
||||
// Helpers for indexed dios/diosm access
|
||||
const dios =
|
||||
(i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.dios?.[i] ?? 0
|
||||
const diosMax =
|
||||
(i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.diosm?.[i] ?? 0
|
||||
const extraDios =
|
||||
(name: string, i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.dios?.[i] ?? 0
|
||||
const extraDiosMax =
|
||||
(name: string, i: number) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.diosm?.[i] ?? 0
|
||||
|
||||
export const diskDataFns = {
|
||||
// usage
|
||||
usage: ({ stats }: SystemStatsRecord) => stats?.du ?? 0,
|
||||
extraUsage:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.du ?? 0,
|
||||
// throughput
|
||||
read: ({ stats }: SystemStatsRecord) => stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024,
|
||||
readMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024,
|
||||
write: ({ stats }: SystemStatsRecord) => stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024,
|
||||
writeMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024,
|
||||
// extra fs throughput
|
||||
extraRead:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.rb ?? (stats?.efs?.[name]?.r ?? 0) * 1024 * 1024,
|
||||
extraReadMax:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.rbm ?? (stats?.efs?.[name]?.rm ?? 0) * 1024 * 1024,
|
||||
extraWrite:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.wb ?? (stats?.efs?.[name]?.w ?? 0) * 1024 * 1024,
|
||||
extraWriteMax:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
stats?.efs?.[name]?.wbm ?? (stats?.efs?.[name]?.wm ?? 0) * 1024 * 1024,
|
||||
// read/write time
|
||||
readTime: dios(0),
|
||||
readTimeMax: diosMax(0),
|
||||
extraReadTime: (name: string) => extraDios(name, 0),
|
||||
extraReadTimeMax: (name: string) => extraDiosMax(name, 0),
|
||||
writeTime: dios(1),
|
||||
writeTimeMax: diosMax(1),
|
||||
extraWriteTime: (name: string) => extraDios(name, 1),
|
||||
extraWriteTimeMax: (name: string) => extraDiosMax(name, 1),
|
||||
// utilization (IoTime-based, 0-100%)
|
||||
util: dios(2),
|
||||
utilMax: diosMax(2),
|
||||
extraUtil: (name: string) => extraDios(name, 2),
|
||||
extraUtilMax: (name: string) => extraDiosMax(name, 2),
|
||||
// r_await / w_await: average service time per read/write operation (ms)
|
||||
rAwait: dios(3),
|
||||
rAwaitMax: diosMax(3),
|
||||
extraRAwait: (name: string) => extraDios(name, 3),
|
||||
extraRAwaitMax: (name: string) => extraDiosMax(name, 3),
|
||||
wAwait: dios(4),
|
||||
wAwaitMax: diosMax(4),
|
||||
extraWAwait: (name: string) => extraDios(name, 4),
|
||||
extraWAwaitMax: (name: string) => extraDiosMax(name, 4),
|
||||
// average queue depth: stored as queue_depth * 100 in Go, divided here
|
||||
weightedIO: ({ stats }: SystemStatsRecord) => (stats?.dios?.[5] ?? 0) / 100,
|
||||
weightedIOMax: ({ stats }: SystemStatsRecord) => (stats?.diosm?.[5] ?? 0) / 100,
|
||||
extraWeightedIO:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
(stats?.efs?.[name]?.dios?.[5] ?? 0) / 100,
|
||||
extraWeightedIOMax:
|
||||
(name: string) =>
|
||||
({ stats }: SystemStatsRecord) =>
|
||||
(stats?.efs?.[name]?.diosm?.[5] ?? 0) / 100,
|
||||
}
|
||||
|
||||
export function RootDiskCharts({ systemData }: { systemData: SystemData }) {
|
||||
return (
|
||||
<>
|
||||
<DiskUsageChart systemData={systemData} />
|
||||
<DiskIOChart systemData={systemData} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiskUsageChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
|
||||
const { chartData, grid, dataEmpty } = systemData
|
||||
|
||||
let diskSize = chartData.systemStats?.at(-1)?.stats.d ?? NaN
|
||||
if (extraFsName) {
|
||||
diskSize = chartData.systemStats?.at(-1)?.stats.efs?.[extraFsName]?.d ?? NaN
|
||||
}
|
||||
// round to nearest GB
|
||||
if (diskSize >= 100) {
|
||||
diskSize = Math.round(diskSize)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={t`Disk Usage`} description={t`Usage of root partition`}>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={[0, diskSize]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue)} ${unit}`
|
||||
}}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Disk Usage`,
|
||||
color: 4,
|
||||
opacity: 0.4,
|
||||
dataKey: ({ stats }) => stats?.du,
|
||||
},
|
||||
]}
|
||||
></AreaChartDefault>
|
||||
</ChartCard>
|
||||
const title = extraFsName ? `${extraFsName} ${t`Usage`}` : t`Disk Usage`
|
||||
const description = extraFsName ? t`Disk usage of ${extraFsName}` : t`Usage of root partition`
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t`Disk I/O`}
|
||||
description={t`Throughput of root filesystem`}
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={showMax}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t({ message: "Write", comment: "Disk write" }),
|
||||
dataKey: ({ stats }: SystemStatsRecord) => {
|
||||
if (showMax) {
|
||||
return stats?.dio?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 3,
|
||||
opacity: 0.3,
|
||||
},
|
||||
{
|
||||
label: t({ message: "Read", comment: "Disk read" }),
|
||||
dataKey: ({ stats }: SystemStatsRecord) => {
|
||||
if (showMax) {
|
||||
return stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
showTotal={true}
|
||||
/>
|
||||
</ChartCard>
|
||||
</>
|
||||
return (
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description}>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={[0, diskSize]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue)} ${unit}`
|
||||
}}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Disk Usage`,
|
||||
color: 4,
|
||||
opacity: 0.4,
|
||||
dataKey: extraFsName ? diskDataFns.extraUsage(extraFsName) : diskDataFns.usage,
|
||||
},
|
||||
]}
|
||||
></AreaChartDefault>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
|
||||
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
const userSettings = useStore($userSettings)
|
||||
|
||||
if (!chartData.systemStats?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const title = extraFsName ? `${extraFsName} I/O` : t`Disk I/O`
|
||||
const description = extraFsName ? t`Throughput of ${extraFsName}` : t`Throughput of root filesystem`
|
||||
|
||||
const hasMoreIOMetrics = chartData.systemStats?.some((record) => record.stats?.dios?.at(0))
|
||||
|
||||
let CornerEl = maxValSelect
|
||||
if (hasMoreIOMetrics) {
|
||||
CornerEl = (
|
||||
<div className="flex gap-2">
|
||||
{maxValSelect}
|
||||
<DiskIoSheet systemData={systemData} extraFsName={extraFsName} title={title} description={description} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
let readFn = showMax ? diskDataFns.readMax : diskDataFns.read
|
||||
let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write
|
||||
if (extraFsName) {
|
||||
readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName)
|
||||
writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description} cornerEl={CornerEl}>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={showMax}
|
||||
// domain={pinnedAxisDomain(true)}
|
||||
showTotal={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t({ message: "Write", comment: "Disk write" }),
|
||||
dataKey: writeFn,
|
||||
color: 3,
|
||||
opacity: 0.3,
|
||||
},
|
||||
{
|
||||
label: t({ message: "Read", comment: "Disk read" }),
|
||||
dataKey: readFn,
|
||||
color: 1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function DiskUtilizationChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
|
||||
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
|
||||
if (!chartData.systemStats?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
let utilFn = showMax ? diskDataFns.utilMax : diskDataFns.util
|
||||
if (extraFsName) {
|
||||
utilFn = showMax ? diskDataFns.extraUtilMax(extraFsName) : diskDataFns.extraUtil(extraFsName)
|
||||
}
|
||||
return (
|
||||
<ChartCard
|
||||
cornerEl={maxValSelect}
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t({
|
||||
message: `I/O Utilization`,
|
||||
context: "Percent of time the disk is busy with I/O",
|
||||
})}
|
||||
description={t`Percent of time the disk is busy with I/O`}
|
||||
// legend={true}
|
||||
className="min-h-auto"
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
maxToggled={showMax}
|
||||
chartProps={{ syncId: "io" }}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t({ message: "Utilization", context: "Disk I/O utilization" }),
|
||||
dataKey: utilFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
|
||||
export function ExtraFsCharts({ systemData }: { systemData: SystemData }) {
|
||||
const { systemStats } = systemData.chartData
|
||||
|
||||
const extraFs = systemStats?.at(-1)?.stats.efs
|
||||
|
||||
if (!extraFs || Object.keys(extraFs).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
{Object.keys(extraFs).map((extraFsName) => {
|
||||
let diskSize = systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN
|
||||
// round to nearest GB
|
||||
if (diskSize >= 100) {
|
||||
diskSize = Math.round(diskSize)
|
||||
}
|
||||
return (
|
||||
<div key={extraFsName} className="contents">
|
||||
<DiskUsageChart systemData={systemData} extraFsName={extraFsName} />
|
||||
|
||||
<DiskIOChart systemData={systemData} extraFsName={extraFsName} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import type { ChartData, SystemStatsRecord } from "@/types"
|
||||
import { ChartCard, SelectAvgMax } from "../chart-card"
|
||||
import { Unit } from "@/lib/enums"
|
||||
|
||||
export function ExtraFsCharts({
|
||||
chartData,
|
||||
grid,
|
||||
dataEmpty,
|
||||
showMax,
|
||||
isLongerChart,
|
||||
maxValues,
|
||||
systemStats,
|
||||
}: {
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
dataEmpty: boolean
|
||||
showMax: boolean
|
||||
isLongerChart: boolean
|
||||
maxValues: boolean
|
||||
systemStats: SystemStatsRecord[]
|
||||
}) {
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
const userSettings = $userSettings.get()
|
||||
const extraFs = systemStats.at(-1)?.stats.efs
|
||||
if (!extraFs || Object.keys(extraFs).length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid xl:grid-cols-2 gap-4">
|
||||
{Object.keys(extraFs).map((extraFsName) => {
|
||||
let diskSize = systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN
|
||||
// round to nearest GB
|
||||
if (diskSize >= 100) {
|
||||
diskSize = Math.round(diskSize)
|
||||
}
|
||||
return (
|
||||
<div key={extraFsName} className="contents">
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${extraFsName} ${t`Usage`}`}
|
||||
description={t`Disk usage of ${extraFsName}`}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={[0, diskSize]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||
return `${decimalString(convertedValue)} ${unit}`
|
||||
}}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Disk Usage`,
|
||||
color: 4,
|
||||
opacity: 0.4,
|
||||
dataKey: ({ stats }) => stats?.efs?.[extraFsName]?.du,
|
||||
},
|
||||
]}
|
||||
></AreaChartDefault>
|
||||
</ChartCard>
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={`${extraFsName} I/O`}
|
||||
description={t`Throughput of ${extraFsName}`}
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
showTotal={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Write`,
|
||||
dataKey: ({ stats }) => {
|
||||
if (showMax) {
|
||||
return stats?.efs?.[extraFsName]?.wbm || (stats?.efs?.[extraFsName]?.wm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return stats?.efs?.[extraFsName]?.wb || (stats?.efs?.[extraFsName]?.w ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 3,
|
||||
opacity: 0.3,
|
||||
},
|
||||
{
|
||||
label: t`Read`,
|
||||
dataKey: ({ stats }) => {
|
||||
if (showMax) {
|
||||
return stats?.efs?.[extraFsName]?.rbm ?? (stats?.efs?.[extraFsName]?.rm ?? 0) * 1024 * 1024
|
||||
}
|
||||
return stats?.efs?.[extraFsName]?.rb ?? (stats?.efs?.[extraFsName]?.r ?? 0) * 1024 * 1024
|
||||
},
|
||||
color: 1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
maxToggled={showMax}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import LineChartDefault, { DataPoint } from "@/components/charts/line-chart"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
import { toFixedFloat, decimalString } from "@/lib/utils"
|
||||
import { useLingui } from "@lingui/react/macro"
|
||||
import { ChartCard, FilterBar } from "../chart-card"
|
||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
||||
import { useMemo } from "react"
|
||||
import { atom } from "nanostores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
|
||||
function probeKey(p: NetworkProbeRecord) {
|
||||
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
||||
return `${p.protocol}:${p.target}`
|
||||
}
|
||||
|
||||
const $filter = atom("")
|
||||
|
||||
export function LatencyChart({
|
||||
probeStats,
|
||||
grid,
|
||||
probes,
|
||||
chartData,
|
||||
empty,
|
||||
}: {
|
||||
probeStats: NetworkProbeStatsRecord[]
|
||||
grid?: boolean
|
||||
probes: NetworkProbeRecord[]
|
||||
chartData: ChartData
|
||||
empty: boolean
|
||||
}) {
|
||||
const { t } = useLingui()
|
||||
const filter = useStore($filter)
|
||||
|
||||
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
|
||||
const count = probes.length
|
||||
return probes
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((p, i) => {
|
||||
const key = probeKey(p)
|
||||
const filterTerms = filter
|
||||
? filter
|
||||
.toLowerCase()
|
||||
.split(" ")
|
||||
.filter((term) => term.length > 0)
|
||||
: []
|
||||
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
|
||||
const strokeOpacity = filtered ? 0.1 : 1
|
||||
return {
|
||||
label: p.name || p.target,
|
||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null,
|
||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||
strokeOpacity,
|
||||
activeDot: !filtered,
|
||||
}
|
||||
})
|
||||
}, [probes, filter])
|
||||
|
||||
return (
|
||||
<ChartCard
|
||||
legend
|
||||
cornerEl={<FilterBar store={$filter} />}
|
||||
empty={empty}
|
||||
title={t`Latency`}
|
||||
description={t`Average round-trip time (ms)`}
|
||||
grid={grid}
|
||||
>
|
||||
<LineChartDefault
|
||||
chartData={chartData}
|
||||
customData={probeStats}
|
||||
dataPoints={dataPoints}
|
||||
domain={pinnedAxisDomain()}
|
||||
connectNulls
|
||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
||||
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
|
||||
legend
|
||||
filter={filter}
|
||||
/>
|
||||
</ChartCard>
|
||||
)
|
||||
}
|
||||
265
internal/site/src/components/routes/system/disk-io-sheet.tsx
Normal file
265
internal/site/src/components/routes/system/disk-io-sheet.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { t } from "@lingui/core/macro"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { MoreHorizontalIcon } from "lucide-react"
|
||||
import { memo, useRef, useState } from "react"
|
||||
import AreaChartDefault from "@/components/charts/area-chart"
|
||||
import ChartTimeSelect from "@/components/charts/chart-time-select"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"
|
||||
import { DialogTitle } from "@/components/ui/dialog"
|
||||
import { $userSettings } from "@/lib/stores"
|
||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
||||
import { ChartCard, SelectAvgMax } from "@/components/routes/system/chart-card"
|
||||
import type { SystemData } from "@/components/routes/system/use-system-data"
|
||||
import { diskDataFns, DiskUtilizationChart } from "./charts/disk-charts"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
|
||||
export default memo(function DiskIOSheet({
|
||||
systemData,
|
||||
extraFsName,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
systemData: SystemData
|
||||
extraFsName?: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
const { chartData, grid, dataEmpty, showMax, maxValues, isLongerChart } = systemData
|
||||
const userSettings = useStore($userSettings)
|
||||
|
||||
const [sheetOpen, setSheetOpen] = useState(false)
|
||||
|
||||
const hasOpened = useRef(false)
|
||||
|
||||
if (sheetOpen && !hasOpened.current) {
|
||||
hasOpened.current = true
|
||||
}
|
||||
|
||||
// throughput functions, with extra fs variants if needed
|
||||
let readFn = showMax ? diskDataFns.readMax : diskDataFns.read
|
||||
let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write
|
||||
if (extraFsName) {
|
||||
readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName)
|
||||
writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName)
|
||||
}
|
||||
|
||||
// read and write time functions, with extra fs variants if needed
|
||||
let readTimeFn = showMax ? diskDataFns.readTimeMax : diskDataFns.readTime
|
||||
let writeTimeFn = showMax ? diskDataFns.writeTimeMax : diskDataFns.writeTime
|
||||
if (extraFsName) {
|
||||
readTimeFn = showMax ? diskDataFns.extraReadTimeMax(extraFsName) : diskDataFns.extraReadTime(extraFsName)
|
||||
writeTimeFn = showMax ? diskDataFns.extraWriteTimeMax(extraFsName) : diskDataFns.extraWriteTime(extraFsName)
|
||||
}
|
||||
|
||||
// I/O await functions, with extra fs variants if needed
|
||||
let rAwaitFn = showMax ? diskDataFns.rAwaitMax : diskDataFns.rAwait
|
||||
let wAwaitFn = showMax ? diskDataFns.wAwaitMax : diskDataFns.wAwait
|
||||
if (extraFsName) {
|
||||
rAwaitFn = showMax ? diskDataFns.extraRAwaitMax(extraFsName) : diskDataFns.extraRAwait(extraFsName)
|
||||
wAwaitFn = showMax ? diskDataFns.extraWAwaitMax(extraFsName) : diskDataFns.extraWAwait(extraFsName)
|
||||
}
|
||||
|
||||
// weighted I/O function, with extra fs variant if needed
|
||||
let weightedIOFn = showMax ? diskDataFns.weightedIOMax : diskDataFns.weightedIO
|
||||
if (extraFsName) {
|
||||
weightedIOFn = showMax ? diskDataFns.extraWeightedIOMax(extraFsName) : diskDataFns.extraWeightedIO(extraFsName)
|
||||
}
|
||||
|
||||
// check for availability of I/O metrics
|
||||
let hasUtilization = false
|
||||
let hasAwait = false
|
||||
let hasWeightedIO = false
|
||||
for (const record of chartData.systemStats ?? []) {
|
||||
const dios = record.stats?.dios
|
||||
if ((dios?.at(2) ?? 0) > 0) hasUtilization = true
|
||||
if ((dios?.at(3) ?? 0) > 0) hasAwait = true
|
||||
if ((dios?.at(5) ?? 0) > 0) hasWeightedIO = true
|
||||
if (hasUtilization && hasAwait && hasWeightedIO) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||
|
||||
const chartProps = { syncId: "io" }
|
||||
|
||||
const queueDepthTranslation = t({ message: "Queue Depth", context: "Disk I/O average queue depth" })
|
||||
|
||||
return (
|
||||
<Sheet open={sheetOpen} onOpenChange={setSheetOpen}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
title={t`View more`}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
{hasOpened.current && (
|
||||
<SheetContent aria-describedby={undefined} className="overflow-auto w-200 !max-w-full p-4 sm:p-6">
|
||||
<ChartTimeSelect className="w-[calc(100%-2em)] bg-card" agentVersion={chartData.agentVersion} />
|
||||
|
||||
<ChartCard
|
||||
className="min-h-auto"
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={title}
|
||||
description={description}
|
||||
cornerEl={maxValSelect}
|
||||
// legend={true}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
showTotal={true}
|
||||
domain={pinnedAxisDomain()}
|
||||
itemSorter={(a, b) => a.order - b.order}
|
||||
reverseStackOrder={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Write`,
|
||||
dataKey: writeFn,
|
||||
color: 3,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
label: t`Read`,
|
||||
dataKey: readFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 1,
|
||||
},
|
||||
]}
|
||||
tickFormatter={(val) => {
|
||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||
}}
|
||||
contentFormatter={({ value }) => {
|
||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
||||
}}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{hasUtilization && <DiskUtilizationChart systemData={systemData} extraFsName={extraFsName} />}
|
||||
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t({ message: "I/O Time", context: "Disk I/O total time spent on read/write" })}
|
||||
description={t({
|
||||
message: "Total time spent on read/write (can exceed 100%)",
|
||||
context: "Disk I/O",
|
||||
})}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)}%`}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
showTotal={true}
|
||||
itemSorter={(a, b) => a.order - b.order}
|
||||
reverseStackOrder={true}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Write`,
|
||||
dataKey: writeTimeFn,
|
||||
color: 3,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
label: t`Read`,
|
||||
dataKey: readTimeFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
stackId: 0,
|
||||
order: 1,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
|
||||
{hasWeightedIO && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={queueDepthTranslation}
|
||||
description={t`Average number of I/O operations waiting to be serviced`}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)}`}
|
||||
contentFormatter={({ value }) => decimalString(value, value < 10 ? 3 : 2)}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
dataPoints={[
|
||||
{
|
||||
label: queueDepthTranslation,
|
||||
dataKey: weightedIOFn,
|
||||
color: 1,
|
||||
opacity: 0.4,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{hasAwait && (
|
||||
<ChartCard
|
||||
empty={dataEmpty}
|
||||
grid={grid}
|
||||
title={t({ message: "I/O Await", context: "Disk I/O average operation time (iostat await)" })}
|
||||
description={t({
|
||||
message: "Average queue to completion time per operation",
|
||||
context: "Disk I/O average operation time (iostat await)",
|
||||
})}
|
||||
className="min-h-auto"
|
||||
cornerEl={maxValSelect}
|
||||
// legend={true}
|
||||
>
|
||||
<AreaChartDefault
|
||||
chartData={chartData}
|
||||
domain={pinnedAxisDomain()}
|
||||
tickFormatter={(val) => `${toFixedFloat(val, 2)} ms`}
|
||||
contentFormatter={({ value }) => `${decimalString(value)} ms`}
|
||||
maxToggled={showMax}
|
||||
chartProps={chartProps}
|
||||
dataPoints={[
|
||||
{
|
||||
label: t`Write`,
|
||||
dataKey: wAwaitFn,
|
||||
color: 3,
|
||||
opacity: 0.3,
|
||||
},
|
||||
{
|
||||
label: t`Read`,
|
||||
dataKey: rAwaitFn,
|
||||
color: 1,
|
||||
opacity: 0.3,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</SheetContent>
|
||||
)}
|
||||
</Sheet>
|
||||
)
|
||||
})
|
||||
@@ -1,6 +1,13 @@
|
||||
import { lazy } from "react"
|
||||
import { lazy, useEffect, useRef, useState } from "react"
|
||||
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { chartTimeData, cn } from "@/lib/utils"
|
||||
import { NetworkProbeRecord, NetworkProbeStatsRecord } from "@/types"
|
||||
import { LatencyChart } from "./charts/probes-charts"
|
||||
import { SystemData } from "./use-system-data"
|
||||
import { $chartTime } from "@/lib/stores"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import system from "../system"
|
||||
import { getStats, appendData } from "./chart-data"
|
||||
|
||||
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
|
||||
|
||||
@@ -34,3 +41,100 @@ export function LazySystemdTable({ systemId }: { systemId: string }) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const NetworkProbesTableNew = lazy(() => import("@/components/network-probes-table/network-probes-table"))
|
||||
|
||||
const cache = new Map<string, any>()
|
||||
|
||||
export function LazyNetworkProbesTableNew({ systemId, systemData }: { systemId: string; systemData: SystemData }) {
|
||||
const { grid, chartData } = systemData ?? {}
|
||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
||||
const chartTime = useStore($chartTime)
|
||||
const [probeStats, setProbeStats] = useState<NetworkProbeStatsRecord[]>([])
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
|
||||
const statsRequestId = useRef(0)
|
||||
|
||||
// get stats when system "changes." (Not just system to system,
|
||||
// also when new info comes in via systemManager realtime connection, indicating an update)
|
||||
useEffect(() => {
|
||||
if (!systemId || !chartTime || chartTime === "1m") {
|
||||
return
|
||||
}
|
||||
|
||||
const { expectedInterval } = chartTimeData[chartTime]
|
||||
const ss_cache_key = `${systemId}${chartTime}`
|
||||
const requestId = ++statsRequestId.current
|
||||
|
||||
const cachedProbeStats = cache.get(ss_cache_key) as NetworkProbeStatsRecord[] | undefined
|
||||
|
||||
// Render from cache immediately if available
|
||||
// if (cachedProbeStats?.length) {
|
||||
// setProbeStats(cachedProbeStats)
|
||||
|
||||
// // Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
||||
// const lastCreated = cachedProbeStats.at(-1)?.created as number | undefined
|
||||
// if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
||||
// return
|
||||
// }
|
||||
// }
|
||||
|
||||
getStats<NetworkProbeStatsRecord>("network_probe_stats", systemId, chartTime, cachedProbeStats).then(
|
||||
(probeStats) => {
|
||||
// If another request has been made since this one, ignore the results
|
||||
if (requestId !== statsRequestId.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// make new system stats
|
||||
let probeStatsData = (cache.get(ss_cache_key) || []) as NetworkProbeStatsRecord[]
|
||||
if (probeStats.length) {
|
||||
probeStatsData = appendData(probeStatsData, probeStats, expectedInterval, 100)
|
||||
cache.set(ss_cache_key, probeStatsData)
|
||||
}
|
||||
setProbeStats(probeStatsData)
|
||||
}
|
||||
)
|
||||
}, [system, chartTime, probes])
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && (
|
||||
<>
|
||||
<NetworkProbesTableNew systemId={systemId} probes={probes} setProbes={setProbes} />
|
||||
{!!chartData && (
|
||||
<LatencyChart
|
||||
probeStats={probeStats}
|
||||
grid={grid}
|
||||
probes={probes}
|
||||
chartData={chartData}
|
||||
empty={!probeStats.length}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const NetworkProbesTable = lazy(() => import("@/components/routes/system/network-probes"))
|
||||
|
||||
export function LazyNetworkProbesTable({
|
||||
system,
|
||||
chartData,
|
||||
grid,
|
||||
probeStats,
|
||||
}: {
|
||||
system: any
|
||||
chartData: any
|
||||
grid: any
|
||||
probeStats: any
|
||||
}) {
|
||||
const { isIntersecting, ref } = useIntersectionObserver()
|
||||
return (
|
||||
<div ref={ref} className={cn(isIntersecting && "contents")}>
|
||||
{isIntersecting && (
|
||||
<NetworkProbesTable system={system} chartData={chartData} grid={grid} realtimeProbeStats={probeStats} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import type { Column, ColumnDef } from "@tanstack/react-table"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn, decimalString } from "@/lib/utils"
|
||||
import {
|
||||
GlobeIcon,
|
||||
TagIcon,
|
||||
TimerIcon,
|
||||
ActivityIcon,
|
||||
WifiOffIcon,
|
||||
Trash2Icon,
|
||||
ArrowLeftRightIcon,
|
||||
MoreHorizontalIcon,
|
||||
} from "lucide-react"
|
||||
import { t } from "@lingui/core/macro"
|
||||
import type { NetworkProbeRecord } from "@/types"
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
|
||||
import { Trans } from "@lingui/react/macro"
|
||||
|
||||
export interface ProbeRow extends NetworkProbeRecord {
|
||||
key: string
|
||||
latency?: number
|
||||
loss?: number
|
||||
}
|
||||
|
||||
const protocolColors: Record<string, string> = {
|
||||
icmp: "bg-blue-500/15 text-blue-400",
|
||||
tcp: "bg-purple-500/15 text-purple-400",
|
||||
http: "bg-green-500/15 text-green-400",
|
||||
}
|
||||
|
||||
export function getProbeColumns(
|
||||
deleteProbe: (id: string) => void,
|
||||
longestName = 0,
|
||||
longestTarget = 0
|
||||
): ColumnDef<ProbeRow>[] {
|
||||
return [
|
||||
{
|
||||
id: "name",
|
||||
sortingFn: (a, b) => (a.original.name || a.original.target).localeCompare(b.original.name || b.original.target),
|
||||
accessorFn: (record) => record.name || record.target,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Name`} Icon={TagIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5 max-w-40 block truncate tabular-nums" style={{ width: `${longestName / 1.05}ch` }}>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
sortingFn: (a, b) => a.original.target.localeCompare(b.original.target),
|
||||
accessorFn: (record) => record.target,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Target`} Icon={GlobeIcon} />,
|
||||
cell: ({ getValue }) => (
|
||||
<div className="ms-1.5 tabular-nums block truncate max-w-44" style={{ width: `${longestTarget / 1.05}ch` }}>
|
||||
{getValue() as string}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
accessorFn: (record) => record.protocol,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Protocol`} Icon={ArrowLeftRightIcon} />,
|
||||
cell: ({ getValue }) => {
|
||||
const protocol = getValue() as string
|
||||
return (
|
||||
<span
|
||||
className={cn("ms-1.5 px-2 py-0.5 rounded text-xs font-medium uppercase", protocolColors[protocol] ?? "")}
|
||||
>
|
||||
{protocol}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "interval",
|
||||
accessorFn: (record) => record.interval,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Interval`} Icon={TimerIcon} />,
|
||||
cell: ({ getValue }) => <span className="ms-1.5 tabular-nums">{getValue() as number}s</span>,
|
||||
},
|
||||
{
|
||||
id: "latency",
|
||||
accessorFn: (record) => record.latency,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Latency`} Icon={ActivityIcon} />,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.latency
|
||||
if (val === undefined) {
|
||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||
}
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||
<span className={cn("shrink-0 size-2 rounded-full", val > 100 ? "bg-yellow-500" : "bg-green-500")} />
|
||||
{decimalString(val, val < 100 ? 2 : 1).toLocaleString()} ms
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "loss",
|
||||
accessorFn: (record) => record.loss,
|
||||
invertSorting: true,
|
||||
header: ({ column }) => <HeaderButton column={column} name={t`Loss`} Icon={WifiOffIcon} />,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original.loss
|
||||
if (val === undefined) {
|
||||
return <span className="ms-1.5 text-muted-foreground">-</span>
|
||||
}
|
||||
return (
|
||||
<span className="ms-1.5 tabular-nums flex gap-2 items-center">
|
||||
<span className={cn("shrink-0 size-2 rounded-full", val > 0 ? "bg-yellow-500" : "bg-green-500")} />
|
||||
{val}%
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
enableSorting: false,
|
||||
header: () => null,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-10"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<span className="sr-only">
|
||||
<Trans>Open menu</Trans>
|
||||
</span>
|
||||
<MoreHorizontalIcon className="w-5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" onClick={(event) => event.stopPropagation()}>
|
||||
<DropdownMenuItem
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
deleteProbe(row.original.id)
|
||||
}}
|
||||
>
|
||||
<Trash2Icon className="me-2.5 size-4" />
|
||||
<Trans>Delete</Trans>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function HeaderButton({ column, name, Icon }: { column: Column<ProbeRow>; name: string; Icon: React.ElementType }) {
|
||||
const isSorted = column.getIsSorted()
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"h-9 px-3 flex items-center gap-2 duration-50",
|
||||
isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90"
|
||||
)}
|
||||
variant="ghost"
|
||||
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{name}
|
||||
{/* <ArrowUpDownIcon className="size-4" /> */}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
372
internal/site/src/components/routes/system/network-probes.tsx
Normal file
372
internal/site/src/components/routes/system/network-probes.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Trans, useLingui } from "@lingui/react/macro"
|
||||
import { pb } from "@/lib/api"
|
||||
import { useStore } from "@nanostores/react"
|
||||
import { $chartTime } from "@/lib/stores"
|
||||
import { chartTimeData, cn, toFixedFloat, decimalString, getVisualStringWidth } from "@/lib/utils"
|
||||
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { useToast } from "@/components/ui/use-toast"
|
||||
import { appendData } from "./chart-data"
|
||||
// import { AddProbeDialog } from "./probe-dialog"
|
||||
import { ChartCard } from "./chart-card"
|
||||
import LineChartDefault, { type DataPoint } from "@/components/charts/line-chart"
|
||||
import { pinnedAxisDomain } from "@/components/ui/chart"
|
||||
import type { ChartData, NetworkProbeRecord, NetworkProbeStatsRecord, SystemRecord } from "@/types"
|
||||
import {
|
||||
type Row,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
|
||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { getProbeColumns, type ProbeRow } from "./network-probes-columns"
|
||||
|
||||
function probeKey(p: NetworkProbeRecord) {
|
||||
if (p.protocol === "tcp") return `${p.protocol}:${p.target}:${p.port}`
|
||||
return `${p.protocol}:${p.target}`
|
||||
}
|
||||
|
||||
export default function NetworkProbes({
|
||||
system,
|
||||
chartData,
|
||||
grid,
|
||||
realtimeProbeStats,
|
||||
}: {
|
||||
system: SystemRecord
|
||||
chartData: ChartData
|
||||
grid: boolean
|
||||
realtimeProbeStats?: NetworkProbeStatsRecord[]
|
||||
}) {
|
||||
const systemId = system.id
|
||||
const [probes, setProbes] = useState<NetworkProbeRecord[]>([])
|
||||
const [stats, setStats] = useState<NetworkProbeStatsRecord[]>([])
|
||||
const [latestResults, setLatestResults] = useState<Record<string, { avg: number; loss: number }>>({})
|
||||
const chartTime = useStore($chartTime)
|
||||
const { toast } = useToast()
|
||||
const { t } = useLingui()
|
||||
|
||||
const fetchProbes = useCallback(() => {
|
||||
pb.collection<NetworkProbeRecord>("network_probes")
|
||||
.getList(0, 2000, {
|
||||
fields: "id,name,target,protocol,port,interval,enabled,updated",
|
||||
filter: systemId ? pb.filter("system={:system}", { system: systemId }) : undefined,
|
||||
})
|
||||
.then((res) => setProbes(res.items))
|
||||
.catch(() => setProbes([]))
|
||||
}, [systemId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProbes()
|
||||
}, [fetchProbes])
|
||||
|
||||
// Build set of current probe keys to filter out deleted probes from stats
|
||||
const activeProbeKeys = useMemo(() => new Set(probes.map(probeKey)), [probes])
|
||||
|
||||
// Use realtime probe stats when in 1m mode
|
||||
useEffect(() => {
|
||||
if (chartTime !== "1m" || !realtimeProbeStats) {
|
||||
return
|
||||
}
|
||||
// Filter stats to only include currently active probes, preserving gap markers
|
||||
const data: NetworkProbeStatsRecord[] = realtimeProbeStats.map((r) => {
|
||||
if (!r.stats) {
|
||||
return r // preserve gap markers from appendData
|
||||
}
|
||||
const filtered: NetworkProbeStatsRecord["stats"] = {}
|
||||
for (const [key, val] of Object.entries(r.stats)) {
|
||||
if (activeProbeKeys.has(key)) {
|
||||
filtered[key] = val
|
||||
}
|
||||
}
|
||||
return { stats: filtered, created: r.created }
|
||||
})
|
||||
setStats(data)
|
||||
// Use last non-gap entry for latest results
|
||||
for (let i = data.length - 1; i >= 0; i--) {
|
||||
if (data[i].stats) {
|
||||
const latest: Record<string, { avg: number; loss: number }> = {}
|
||||
for (const [key, val] of Object.entries(data[i].stats)) {
|
||||
latest[key] = { avg: val?.[0], loss: val?.[3] }
|
||||
}
|
||||
setLatestResults(latest)
|
||||
break
|
||||
}
|
||||
}
|
||||
}, [chartTime, realtimeProbeStats, activeProbeKeys])
|
||||
|
||||
// Fetch probe stats based on chart time (skip in realtime mode)
|
||||
useEffect(() => {
|
||||
if (probes.length === 0) {
|
||||
setStats([])
|
||||
setLatestResults({})
|
||||
return
|
||||
}
|
||||
if (chartTime === "1m") {
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
const { type: statsType = "1m", expectedInterval } = chartTimeData[chartTime] ?? {}
|
||||
|
||||
console.log("Fetching probe stats", { systemId, statsType, expectedInterval })
|
||||
|
||||
pb.collection<NetworkProbeStatsRecord>("network_probe_stats")
|
||||
.getList(0, 2000, {
|
||||
fields: "stats,created",
|
||||
filter: pb.filter("system={:system} && type={:type} && created <= {:created}", {
|
||||
system: systemId,
|
||||
type: statsType,
|
||||
created: new Date(Date.now() - 60 * 60 * 1000).toISOString(),
|
||||
}),
|
||||
sort: "-created",
|
||||
})
|
||||
.then((raw) => {
|
||||
console.log("Fetched probe stats", { raw })
|
||||
// Filter stats to only include currently active probes
|
||||
const mapped: NetworkProbeStatsRecord[] = raw.items.map((r) => {
|
||||
const filtered: NetworkProbeStatsRecord["stats"] = {}
|
||||
for (const [key, val] of Object.entries(r.stats)) {
|
||||
if (activeProbeKeys.has(key)) {
|
||||
filtered[key] = val
|
||||
}
|
||||
}
|
||||
return { stats: filtered, created: new Date(r.created).getTime() }
|
||||
})
|
||||
// Apply gap detection — inserts null markers where data is missing
|
||||
const data = appendData([] as NetworkProbeStatsRecord[], mapped, expectedInterval)
|
||||
setStats(data)
|
||||
if (mapped.length > 0) {
|
||||
const last = mapped[mapped.length - 1].stats
|
||||
const latest: Record<string, { avg: number; loss: number }> = {}
|
||||
for (const [key, val] of Object.entries(last)) {
|
||||
latest[key] = { avg: val?.[0], loss: val?.[3] }
|
||||
}
|
||||
setLatestResults(latest)
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Error fetching probe stats", e)
|
||||
setStats([])
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [system, chartTime, probes, activeProbeKeys])
|
||||
|
||||
const deleteProbe = useCallback(
|
||||
async (id: string) => {
|
||||
try {
|
||||
await pb.collection("network_probes").delete(id)
|
||||
// fetchProbes()
|
||||
} catch (err: unknown) {
|
||||
toast({ variant: "destructive", title: t`Error`, description: (err as Error)?.message })
|
||||
}
|
||||
},
|
||||
[systemId, t]
|
||||
)
|
||||
|
||||
const dataPoints: DataPoint<NetworkProbeStatsRecord>[] = useMemo(() => {
|
||||
const count = probes.length
|
||||
return probes.map((p, i) => {
|
||||
const key = probeKey(p)
|
||||
return {
|
||||
label: p.name || p.target,
|
||||
dataKey: (record: NetworkProbeStatsRecord) => record.stats?.[key]?.[0] ?? null,
|
||||
color: count <= 5 ? i + 1 : `hsl(${(i * 360) / count}, var(--chart-saturation), var(--chart-lightness))`,
|
||||
}
|
||||
})
|
||||
}, [probes])
|
||||
|
||||
const { longestName, longestTarget } = useMemo(() => {
|
||||
let longestName = 0
|
||||
let longestTarget = 0
|
||||
for (const p of probes) {
|
||||
longestName = Math.max(longestName, getVisualStringWidth(p.name || p.target))
|
||||
longestTarget = Math.max(longestTarget, getVisualStringWidth(p.target))
|
||||
}
|
||||
return { longestName, longestTarget }
|
||||
}, [probes])
|
||||
|
||||
const columns = useMemo(
|
||||
() => getProbeColumns(deleteProbe, longestName, longestTarget),
|
||||
[deleteProbe, longestName, longestTarget]
|
||||
)
|
||||
|
||||
const tableData: ProbeRow[] = useMemo(
|
||||
() =>
|
||||
probes.map((p) => {
|
||||
const key = probeKey(p)
|
||||
const result = latestResults[key]
|
||||
return { ...p, key, latency: result?.avg, loss: result?.loss }
|
||||
}),
|
||||
[probes, latestResults]
|
||||
)
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([{ id: "name", desc: false }])
|
||||
|
||||
const table = useReactTable({
|
||||
data: tableData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
onSortingChange: setSorting,
|
||||
defaultColumn: {
|
||||
sortUndefined: "last",
|
||||
size: 100,
|
||||
minSize: 0,
|
||||
},
|
||||
state: { sorting },
|
||||
})
|
||||
|
||||
const rows = table.getRowModel().rows
|
||||
const visibleColumns = table.getVisibleLeafColumns()
|
||||
|
||||
// if (probes.length === 0 && stats.length === 0) {
|
||||
// return (
|
||||
// <Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
// <CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
// <div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
|
||||
// <div className="px-2 sm:px-1">
|
||||
// <CardTitle className="mb-2">
|
||||
// <Trans>Network Probes</Trans>
|
||||
// </CardTitle>
|
||||
// <CardDescription>
|
||||
// <Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
|
||||
// </CardDescription>
|
||||
// </div>
|
||||
// {/* <div className="relative ms-auto w-full max-w-full md:w-64"> */}
|
||||
// <AddProbeDialog systemId={systemId} onCreated={fetchProbes} />
|
||||
// {/* </div> */}
|
||||
// </div>
|
||||
// </CardHeader>
|
||||
// </Card>
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// console.log("Rendering NetworkProbes", { probes, stats })
|
||||
|
||||
return (
|
||||
<div className="grid gap-4">
|
||||
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
|
||||
<CardHeader className="p-0 mb-3 sm:mb-4">
|
||||
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end md:justify-between">
|
||||
<div className="px-2 sm:px-1">
|
||||
<CardTitle>
|
||||
<Trans>Network Probes</Trans>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1.5">
|
||||
<Trans>ICMP/TCP/HTTP latency monitoring from this agent</Trans>
|
||||
</CardDescription>
|
||||
</div>
|
||||
{/* <AddProbeDialog systemId={systemId} onCreated={fetchProbes} /> */}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<ProbesTable table={table} rows={rows} colLength={visibleColumns.length} />
|
||||
</Card>
|
||||
|
||||
{stats.length > 0 && (
|
||||
<ChartCard title={t`Latency`} description={t`Average round-trip time (ms)`} grid={grid}>
|
||||
<LineChartDefault
|
||||
chartData={chartData}
|
||||
customData={stats}
|
||||
dataPoints={dataPoints}
|
||||
domain={pinnedAxisDomain()}
|
||||
connectNulls
|
||||
tickFormatter={(value) => `${toFixedFloat(value, value >= 10 ? 0 : 1)} ms`}
|
||||
contentFormatter={({ value }) => `${decimalString(value, 2)} ms`}
|
||||
legend
|
||||
/>
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProbesTable = memo(function ProbesTable({
|
||||
table,
|
||||
rows,
|
||||
colLength,
|
||||
}: {
|
||||
table: ReturnType<typeof useReactTable<ProbeRow>>
|
||||
rows: Row<ProbeRow>[]
|
||||
colLength: number
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
||||
count: rows.length,
|
||||
estimateSize: () => 54,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
overscan: 5,
|
||||
})
|
||||
const virtualRows = virtualizer.getVirtualItems()
|
||||
|
||||
const paddingTop = Math.max(0, virtualRows[0]?.start ?? 0 - virtualizer.options.scrollMargin)
|
||||
const paddingBottom = Math.max(0, virtualizer.getTotalSize() - (virtualRows[virtualRows.length - 1]?.end ?? 0))
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-min max-h-[calc(100dvh-17rem)] w-full relative overflow-auto rounded-md border",
|
||||
(!rows.length || rows.length > 2) && "min-h-50"
|
||||
)}
|
||||
ref={scrollRef}
|
||||
>
|
||||
<div style={{ height: `${virtualizer.getTotalSize() + 48}px`, paddingTop, paddingBottom }}>
|
||||
<table className="w-full text-sm text-nowrap">
|
||||
<ProbesTableHead table={table} />
|
||||
<TableBody>
|
||||
{rows.length ? (
|
||||
virtualRows.map((virtualRow) => {
|
||||
const row = rows[virtualRow.index]
|
||||
return <ProbesTableRow key={row.id} row={row} virtualRow={virtualRow} />
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
|
||||
<Trans>No results.</Trans>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
function ProbesTableHead({ table }: { table: ReturnType<typeof useReactTable<ProbeRow>> }) {
|
||||
return (
|
||||
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead className="px-2" key={header.id}>
|
||||
{header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableHead>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</TableHeader>
|
||||
)
|
||||
}
|
||||
|
||||
const ProbesTableRow = memo(function ProbesTableRow({
|
||||
row,
|
||||
virtualRow,
|
||||
}: {
|
||||
row: Row<ProbeRow>
|
||||
virtualRow: VirtualItem
|
||||
}) {
|
||||
return (
|
||||
<TableRow>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-0" style={{ height: virtualRow.size }}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
})
|
||||
@@ -36,7 +36,7 @@ import { Input } from "@/components/ui/input"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { pb } from "@/lib/api"
|
||||
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
||||
import {
|
||||
formatBytes,
|
||||
@@ -492,7 +492,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
||||
const tableColumns = useMemo(() => {
|
||||
const columns = createColumns(longestName, longestModel, longestDevice)
|
||||
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||
return [...baseColumns, actionColumn]
|
||||
return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
|
||||
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
||||
|
||||
const table = useReactTable({
|
||||
|
||||
@@ -19,6 +19,7 @@ import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/uti
|
||||
import type {
|
||||
ChartData,
|
||||
ContainerStatsRecord,
|
||||
NetworkProbeStatsRecord,
|
||||
SystemDetailsRecord,
|
||||
SystemInfo,
|
||||
SystemRecord,
|
||||
@@ -28,6 +29,8 @@ import type {
|
||||
import { $router, navigate } from "../../router"
|
||||
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
|
||||
|
||||
export type SystemData = ReturnType<typeof useSystemData>
|
||||
|
||||
export function useSystemData(id: string) {
|
||||
const direction = useStore($direction)
|
||||
const systems = useStore($systems)
|
||||
@@ -46,6 +49,7 @@ export function useSystemData(id: string) {
|
||||
const [system, setSystem] = useState({} as SystemRecord)
|
||||
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
|
||||
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
|
||||
const [probeStats, setProbeStats] = useState([] as NetworkProbeStatsRecord[])
|
||||
const persistChartTime = useRef(false)
|
||||
const statsRequestId = useRef(0)
|
||||
const [chartLoading, setChartLoading] = useState(true)
|
||||
@@ -117,24 +121,34 @@ export function useSystemData(id: string) {
|
||||
pb.realtime
|
||||
.subscribe(
|
||||
`rt_metrics`,
|
||||
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
|
||||
(data: {
|
||||
container: ContainerStatsRecord[]
|
||||
info: SystemInfo
|
||||
stats: SystemStats
|
||||
probes?: NetworkProbeStatsRecord["stats"]
|
||||
}) => {
|
||||
const now = Date.now()
|
||||
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
|
||||
const containerPoint =
|
||||
data.container?.length > 0
|
||||
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
|
||||
: null
|
||||
const probePoint: NetworkProbeStatsRecord | null = data.probes ? { stats: data.probes, created: now } : null
|
||||
// on first message, make sure we clear out data from other time periods
|
||||
if (isFirst) {
|
||||
isFirst = false
|
||||
setSystemStats([statsPoint])
|
||||
setContainerData(containerPoint ? [containerPoint] : [])
|
||||
setProbeStats(probePoint ? [probePoint] : [])
|
||||
return
|
||||
}
|
||||
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
|
||||
if (containerPoint) {
|
||||
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
|
||||
}
|
||||
if (probePoint) {
|
||||
setProbeStats((prev) => appendData(prev, [probePoint], 1000, 60))
|
||||
}
|
||||
},
|
||||
{ query: { system: system.id } }
|
||||
)
|
||||
@@ -190,7 +204,7 @@ export function useSystemData(id: string) {
|
||||
|
||||
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
||||
const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined
|
||||
if (lastCreated && Date.now() - lastCreated < expectedInterval) {
|
||||
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -198,8 +212,8 @@ export function useSystemData(id: string) {
|
||||
}
|
||||
|
||||
Promise.allSettled([
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
|
||||
getStats<SystemStatsRecord>("system_stats", systemId, chartTime, cachedSystemStats),
|
||||
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime, cachedContainerData),
|
||||
]).then(([systemStats, containerStats]) => {
|
||||
// If another request has been made since this one, ignore the results
|
||||
if (requestId !== statsRequestId.current) {
|
||||
@@ -320,6 +334,7 @@ export function useSystemData(id: string) {
|
||||
system,
|
||||
systemStats,
|
||||
containerData,
|
||||
probeStats,
|
||||
chartData,
|
||||
containerChartConfigs,
|
||||
details,
|
||||
|
||||
@@ -110,20 +110,23 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
||||
|
||||
// match filter value against name or translated status
|
||||
return (row, _, newFilterInput) => {
|
||||
const { name, status } = row.original
|
||||
const sys = row.original
|
||||
if (sys.host.includes(newFilterInput) || sys.info.v?.includes(newFilterInput)) {
|
||||
return true
|
||||
}
|
||||
if (newFilterInput !== filterInput) {
|
||||
filterInput = newFilterInput
|
||||
filterInputLower = newFilterInput.toLowerCase()
|
||||
}
|
||||
let nameLower = nameCache.get(name)
|
||||
let nameLower = nameCache.get(sys.name)
|
||||
if (nameLower === undefined) {
|
||||
nameLower = name.toLowerCase()
|
||||
nameCache.set(name, nameLower)
|
||||
nameLower = sys.name.toLowerCase()
|
||||
nameCache.set(sys.name, nameLower)
|
||||
}
|
||||
if (nameLower.includes(filterInputLower)) {
|
||||
return true
|
||||
}
|
||||
const statusLower = statusTranslations[status as keyof typeof statusTranslations]
|
||||
const statusLower = statusTranslations[sys.status as keyof typeof statusTranslations]
|
||||
return statusLower?.includes(filterInputLower) || false
|
||||
}
|
||||
})(),
|
||||
|
||||
@@ -460,14 +460,14 @@ const SystemCard = memo(
|
||||
}
|
||||
)}
|
||||
>
|
||||
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
|
||||
<div className="flex items-center gap-2 w-full overflow-hidden">
|
||||
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
|
||||
<CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
|
||||
<div className="flex items-center gap-1 w-full overflow-hidden">
|
||||
<h3 className="text-primary/90 min-w-0 flex-1 gap-2.5 font-semibold">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<IndicatorDot system={system} />
|
||||
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</h3>
|
||||
{table.getColumn("actions")?.getIsVisible() && (
|
||||
<div className="flex gap-1 shrink-0 relative z-10">
|
||||
<AlertButton system={system} />
|
||||
|
||||
@@ -43,7 +43,7 @@ const AlertDialogContent = React.forwardRef<
|
||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||
|
||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("grid gap-2 text-center sm:text-start", className)} {...props} />
|
||||
<div className={cn("grid gap-2 text-start", className)} {...props} />
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
|
||||
@@ -18,11 +18,7 @@ CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn("text-[1.4em] sm:text-2xl font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
<h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
|
||||
)
|
||||
)
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
@@ -402,7 +402,7 @@ function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key:
|
||||
|
||||
let cachedAxis: JSX.Element
|
||||
const xAxis = ({ domain, ticks, chartTime }: ChartData) => {
|
||||
if (cachedAxis && domain[0] === cachedAxis.props.domain[0]) {
|
||||
if (cachedAxis && ticks === cachedAxis.props.ticks) {
|
||||
return cachedAxis
|
||||
}
|
||||
cachedAxis = (
|
||||
|
||||
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("grid gap-1.5 text-center sm:text-start", className)} {...props} />
|
||||
<div className={cn("grid gap-1.5 text-start", className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
|
||||
@@ -177,6 +177,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
@utility text-card-title {
|
||||
@apply text-[1.4rem] sm:text-2xl;
|
||||
}
|
||||
|
||||
.recharts-tooltip-wrapper {
|
||||
z-index: 51;
|
||||
@apply tabular-nums;
|
||||
|
||||
@@ -8,7 +8,7 @@ msgstr ""
|
||||
"Language: ar\n"
|
||||
"Project-Id-Version: beszel\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
||||
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||
"Last-Translator: \n"
|
||||
"Language-Team: Arabic\n"
|
||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||
@@ -22,7 +22,7 @@ msgstr ""
|
||||
#: src/components/footer-repo-link.tsx
|
||||
msgctxt "New version available"
|
||||
msgid "{0} available"
|
||||
msgstr ""
|
||||
msgstr "{0} متاح"
|
||||
|
||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||
@@ -209,10 +209,19 @@ msgstr "المتوسط ينخفض أقل من <0>{value}{0}</0>"
|
||||
msgid "Average exceeds <0>{value}{0}</0>"
|
||||
msgstr "المتوسط يتجاوز <0>{value}{0}</0>"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Average number of I/O operations waiting to be serviced"
|
||||
msgstr "متوسط عدد عمليات الإدخال والإخراج التي تنتظر خدمتها"
|
||||
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
msgid "Average power consumption of GPUs"
|
||||
msgstr "متوسط استهلاك طاقة وحدة معالجة الرسوميات"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "Average queue to completion time per operation"
|
||||
msgstr "متوسط الوقت من الانتظار في الدور حتى الإتمام لكل عملية"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Average system-wide CPU utilization"
|
||||
msgstr "متوسط استخدام وحدة المعالجة المركزية على مستوى النظام"
|
||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
||||
msgid "Copy env"
|
||||
msgstr "نسخ متغيرات البيئة"
|
||||
|
||||
#: src/components/alerts/alerts-sheet.tsx
|
||||
msgctxt "Copy alerts from another system"
|
||||
msgid "Copy from"
|
||||
msgstr "نسخ من"
|
||||
|
||||
#: src/components/systems-table/systems-table-columns.tsx
|
||||
msgid "Copy host"
|
||||
msgstr "نسخ المضيف"
|
||||
@@ -476,7 +490,7 @@ msgstr "نسخ YAML"
|
||||
#: src/components/routes/system.tsx
|
||||
msgctxt "Core system metrics"
|
||||
msgid "Core"
|
||||
msgstr ""
|
||||
msgstr "النواة"
|
||||
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||
@@ -550,7 +564,7 @@ msgstr "يوميًا"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Default system layout option"
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
msgstr "افتراضي"
|
||||
|
||||
#: src/components/routes/settings/general.tsx
|
||||
msgid "Default time period"
|
||||
@@ -599,19 +613,18 @@ msgstr "وحدة القرص"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/lib/alerts.ts
|
||||
msgid "Disk Usage"
|
||||
msgstr "استخدام القرص"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Disk usage of {extraFsName}"
|
||||
msgstr "استخدام القرص لـ {extraFsName}"
|
||||
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Layout display options"
|
||||
msgid "Display"
|
||||
msgstr ""
|
||||
msgstr "عرض"
|
||||
|
||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||
msgid "Docker CPU Usage"
|
||||
@@ -898,6 +911,21 @@ msgstr "طريقة HTTP"
|
||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||
msgstr "طريقة HTTP: POST، GET، أو HEAD (الافتراضي: POST)"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average operation time (iostat await)"
|
||||
msgid "I/O Await"
|
||||
msgstr "انتظار الإدخال والإخراج"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O total time spent on read/write"
|
||||
msgid "I/O Time"
|
||||
msgstr "وقت الإدخال والإخراج"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Percent of time the disk is busy with I/O"
|
||||
msgid "I/O Utilization"
|
||||
msgstr "استخدام الإدخال والإخراج"
|
||||
|
||||
#. Context: Battery state
|
||||
#: src/lib/i18n.ts
|
||||
msgid "Idle"
|
||||
@@ -1207,6 +1235,10 @@ msgstr "تنسيق الحمولة"
|
||||
msgid "Per-core average utilization"
|
||||
msgstr "متوسط الاستخدام لكل نواة"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Percent of time the disk is busy with I/O"
|
||||
msgstr "النسبة المئوية للوقت الذي يكون فيه القرص مشغولاً بالإدخال والإخراج"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
msgid "Percentage of time spent in each state"
|
||||
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
|
||||
@@ -1259,7 +1291,7 @@ msgstr "المنفذ"
|
||||
#: src/components/containers-table/containers-table-columns.tsx
|
||||
msgctxt "Container ports"
|
||||
msgid "Ports"
|
||||
msgstr ""
|
||||
msgstr "المنافذ"
|
||||
|
||||
#. Power On Time
|
||||
#: src/components/routes/system/smart-table.tsx
|
||||
@@ -1284,13 +1316,20 @@ msgstr "تم بدء العملية"
|
||||
msgid "Public Key"
|
||||
msgstr "المفتاح العام"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O average queue depth"
|
||||
msgid "Queue Depth"
|
||||
msgstr "عمق الدور"
|
||||
|
||||
#: src/components/routes/settings/quiet-hours.tsx
|
||||
msgid "Quiet Hours"
|
||||
msgstr "ساعات الهدوء"
|
||||
|
||||
#. Disk read
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Read"
|
||||
msgstr "قراءة"
|
||||
|
||||
@@ -1549,7 +1588,7 @@ msgstr "جدول"
|
||||
#: src/components/routes/system/info-bar.tsx
|
||||
msgctxt "Tabs system layout option"
|
||||
msgid "Tabs"
|
||||
msgstr ""
|
||||
msgstr "تبويبات"
|
||||
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Tasks"
|
||||
@@ -1602,7 +1641,7 @@ msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذل
|
||||
msgid "This will permanently delete all selected records from the database."
|
||||
msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة من قاعدة البيانات بشكل دائم."
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgid "Throughput of {extraFsName}"
|
||||
msgstr "معدل نقل {extraFsName}"
|
||||
|
||||
@@ -1655,6 +1694,11 @@ msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
||||
msgid "Total data sent for each interface"
|
||||
msgstr "إجمالي البيانات المرسلة لكل واجهة"
|
||||
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgctxt "Disk I/O"
|
||||
msgid "Total time spent on read/write (can exceed 100%)"
|
||||
msgstr ""
|
||||
|
||||
#. placeholder {0}: data.length
|
||||
#: src/components/systemd-table/systemd-table.tsx
|
||||
msgid "Total: {0}"
|
||||
@@ -1775,7 +1819,7 @@ msgstr "رفع"
|
||||
msgid "Uptime"
|
||||
msgstr "مدة التشغيل"
|
||||
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||
@@ -1797,6 +1841,11 @@ msgstr "مستخدم"
|
||||
msgid "Users"
|
||||
msgstr "المستخدمون"
|
||||
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
msgctxt "Disk I/O utilization"
|
||||
msgid "Utilization"
|
||||
msgstr "الاستخدام"
|
||||
|
||||
#: src/components/alerts-history-columns.tsx
|
||||
msgid "Value"
|
||||
msgstr "القيمة"
|
||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
||||
msgstr "عرض"
|
||||
|
||||
#: src/components/routes/system/cpu-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/network-sheet.tsx
|
||||
msgid "View more"
|
||||
msgstr "عرض المزيد"
|
||||
@@ -1858,7 +1908,9 @@ msgstr "أمر ويندوز"
|
||||
|
||||
#. Disk write
|
||||
#: src/components/routes/system/charts/disk-charts.tsx
|
||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
#: src/components/routes/system/disk-io-sheet.tsx
|
||||
msgid "Write"
|
||||
msgstr "كتابة"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user