mirror of
https://github.com/henrygd/beszel.git
synced 2026-04-19 11:21:50 +02:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd9ea51039 | ||
|
|
a71617e058 | ||
|
|
e5507fa106 | ||
|
|
a024c3cfd0 | ||
|
|
07466804e7 | ||
|
|
981c788d6f | ||
|
|
f5576759de | ||
|
|
be0b708064 | ||
|
|
ab3a3de46c | ||
|
|
1556e53926 | ||
|
|
e3ade3aeb8 | ||
|
|
b013f06956 | ||
|
|
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 | ||
|
|
5db4eb4346 | ||
|
|
f6c5e2928a | ||
|
|
6a207c33fa | ||
|
|
9f19afccde | ||
|
|
f25f2469e3 | ||
|
|
5bd43ed461 | ||
|
|
afdc3f7779 | ||
|
|
a227c77526 | ||
|
|
8202d746af |
@@ -19,6 +19,8 @@ import (
|
|||||||
gossh "golang.org/x/crypto/ssh"
|
gossh "golang.org/x/crypto/ssh"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultDataCacheTimeMs uint16 = 60_000
|
||||||
|
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
sync.Mutex // Used to lock agent while collecting data
|
sync.Mutex // Used to lock agent while collecting data
|
||||||
debug bool // true if LOG_LEVEL is set to debug
|
debug bool // true if LOG_LEVEL is set to debug
|
||||||
@@ -36,6 +38,7 @@ type Agent struct {
|
|||||||
sensorConfig *SensorConfig // Sensors config
|
sensorConfig *SensorConfig // Sensors config
|
||||||
systemInfo system.Info // Host system info (dynamic)
|
systemInfo system.Info // Host system info (dynamic)
|
||||||
systemDetails system.Details // Host system details (static, once-per-connection)
|
systemDetails system.Details // Host system details (static, once-per-connection)
|
||||||
|
detailsDirty bool // Whether system details have changed and need to be resent
|
||||||
gpuManager *GPUManager // Manages GPU data
|
gpuManager *GPUManager // Manages GPU data
|
||||||
cache *systemDataCache // Cache for system stats based on cache time
|
cache *systemDataCache // Cache for system stats based on cache time
|
||||||
connectionManager *ConnectionManager // Channel to signal connection events
|
connectionManager *ConnectionManager // Channel to signal connection events
|
||||||
@@ -97,7 +100,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
slog.Debug(beszel.Version)
|
slog.Debug(beszel.Version)
|
||||||
|
|
||||||
// initialize docker manager
|
// initialize docker manager
|
||||||
agent.dockerManager = newDockerManager()
|
agent.dockerManager = newDockerManager(agent)
|
||||||
|
|
||||||
// initialize system info
|
// initialize system info
|
||||||
agent.refreshSystemDetails()
|
agent.refreshSystemDetails()
|
||||||
@@ -142,7 +145,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
|
|||||||
|
|
||||||
// if debugging, print stats
|
// if debugging, print stats
|
||||||
if agent.debug {
|
if agent.debug {
|
||||||
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000, IncludeDetails: true}))
|
slog.Debug("Stats", "data", agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs, IncludeDetails: true}))
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent, nil
|
return agent, nil
|
||||||
@@ -164,11 +167,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
Info: a.systemInfo,
|
Info: a.systemInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Include static system details only when requested
|
|
||||||
if options.IncludeDetails {
|
|
||||||
data.Details = &a.systemDetails
|
|
||||||
}
|
|
||||||
|
|
||||||
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
// slog.Info("System data", "data", data, "cacheTimeMs", cacheTimeMs)
|
||||||
|
|
||||||
if a.dockerManager != nil {
|
if a.dockerManager != nil {
|
||||||
@@ -181,7 +179,7 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
}
|
}
|
||||||
|
|
||||||
// skip updating systemd services if cache time is not the default 60sec interval
|
// skip updating systemd services if cache time is not the default 60sec interval
|
||||||
if a.systemdManager != nil && cacheTimeMs == 60_000 {
|
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
|
||||||
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
totalCount := uint16(a.systemdManager.getServiceStatsCount())
|
||||||
if totalCount > 0 {
|
if totalCount > 0 {
|
||||||
numFailed := a.systemdManager.getFailedServiceCount()
|
numFailed := a.systemdManager.getFailedServiceCount()
|
||||||
@@ -212,7 +210,8 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
|
|||||||
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
slog.Debug("Extra FS", "data", data.Stats.ExtraFs)
|
||||||
|
|
||||||
a.cache.Set(data, cacheTimeMs)
|
a.cache.Set(data, cacheTimeMs)
|
||||||
return data
|
|
||||||
|
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start initializes and starts the agent with optional WebSocket connection
|
// Start initializes and starts the agent with optional WebSocket connection
|
||||||
|
|||||||
@@ -1,84 +1,11 @@
|
|||||||
//go:build !freebsd
|
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
|
||||||
|
|
||||||
// Package battery provides functions to check if the system has a battery and to get the battery stats.
|
|
||||||
package battery
|
package battery
|
||||||
|
|
||||||
import (
|
const (
|
||||||
"errors"
|
stateUnknown uint8 = iota
|
||||||
"log/slog"
|
stateEmpty
|
||||||
"math"
|
stateFull
|
||||||
|
stateCharging
|
||||||
"github.com/distatus/battery"
|
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
|
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/fxamacker/cbor/v2"
|
||||||
"github.com/lxzan/gws"
|
"github.com/lxzan/gws"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -104,6 +105,11 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
}
|
}
|
||||||
client.hubURL.Path = path.Join(client.hubURL.Path, "api/beszel/agent-connect")
|
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{
|
client.options = &gws.ClientOption{
|
||||||
Addr: client.hubURL.String(),
|
Addr: client.hubURL.String(),
|
||||||
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
TlsConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
@@ -112,6 +118,9 @@ func (client *WebSocketClient) getOptions() *gws.ClientOption {
|
|||||||
"X-Token": []string{client.token},
|
"X-Token": []string{client.token},
|
||||||
"X-Beszel": []string{beszel.Version},
|
"X-Beszel": []string{beszel.Version},
|
||||||
},
|
},
|
||||||
|
NewDialer: func() (gws.Dialer, error) {
|
||||||
|
return proxy.FromEnvironment(), nil
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return client.options
|
return client.options
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,15 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/agent/health"
|
"github.com/henrygd/beszel/agent/health"
|
||||||
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/system"
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -111,13 +115,36 @@ func (c *ConnectionManager) Start(serverOptions ServerOptions) error {
|
|||||||
_ = health.Update()
|
_ = health.Update()
|
||||||
case <-sigCtx.Done():
|
case <-sigCtx.Done():
|
||||||
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
slog.Info("Shutting down", "cause", context.Cause(sigCtx))
|
||||||
_ = c.agent.StopServer()
|
return c.stop()
|
||||||
c.closeWebSocket()
|
|
||||||
return health.CleanUp()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.closeWebSocket()
|
||||||
|
return health.CleanUp()
|
||||||
|
}
|
||||||
|
|
||||||
// handleEvent processes connection events and updates the connection state accordingly.
|
// handleEvent processes connection events and updates the connection state accordingly.
|
||||||
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
func (c *ConnectionManager) handleEvent(event ConnectionEvent) {
|
||||||
switch event {
|
switch event {
|
||||||
@@ -185,9 +212,16 @@ func (c *ConnectionManager) connect() {
|
|||||||
|
|
||||||
// Try WebSocket first, if it fails, start SSH server
|
// Try WebSocket first, if it fails, start SSH server
|
||||||
err := c.startWebSocketConnection()
|
err := c.startWebSocketConnection()
|
||||||
if err != nil && c.State == Disconnected {
|
if err != nil {
|
||||||
c.startSSHServer()
|
if shouldExitOnErr(err) {
|
||||||
c.startWsTicker()
|
time.Sleep(2 * time.Second) // prevent tight restart loop
|
||||||
|
_ = c.stop()
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if c.State == Disconnected {
|
||||||
|
c.startSSHServer()
|
||||||
|
c.startWsTicker()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,3 +258,14 @@ func (c *ConnectionManager) closeWebSocket() {
|
|||||||
c.wsClient.Close()
|
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 (
|
import (
|
||||||
"crypto/ed25519"
|
"crypto/ed25519"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -298,3 +299,65 @@ func TestConnectionManager_ConnectFlow(t *testing.T) {
|
|||||||
cm.connect()
|
cm.connect()
|
||||||
}, "Connect should not panic without WebSocket client")
|
}, "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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
111
agent/disk.go
111
agent/disk.go
@@ -1,6 +1,7 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -33,6 +34,34 @@ type diskDiscovery struct {
|
|||||||
ctx fsRegistrationContext
|
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"
|
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
|
||||||
// Returns the device/filesystem part and the custom name part
|
// Returns the device/filesystem part and the custom name part
|
||||||
func parseFilesystemEntry(entry string) (device, customName string) {
|
func parseFilesystemEntry(entry string) (device, customName string) {
|
||||||
@@ -238,9 +267,11 @@ func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
|
|||||||
|
|
||||||
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
|
||||||
// their display names can come from the folder name while their I/O keys still
|
// 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) {
|
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
|
||||||
if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) {
|
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
device, customName := extraFilesystemPartitionInfo(p)
|
device, customName := extraFilesystemPartitionInfo(p)
|
||||||
@@ -273,7 +304,7 @@ func (a *Agent) initializeDiskInfo() {
|
|||||||
hasRoot := false
|
hasRoot := false
|
||||||
isWindows := runtime.GOOS == "windows"
|
isWindows := runtime.GOOS == "windows"
|
||||||
|
|
||||||
partitions, err := disk.Partitions(false)
|
partitions, err := disk.PartitionsWithContext(context.Background(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Error getting disk partitions", "err", err)
|
slog.Error("Error getting disk partitions", "err", err)
|
||||||
}
|
}
|
||||||
@@ -578,16 +609,29 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
prev, hasPrev := a.diskPrev[cacheTimeMs][name]
|
||||||
if !hasPrev {
|
if !hasPrev {
|
||||||
// Seed from agent-level fsStats if present, else seed from current
|
// 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() {
|
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())
|
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 {
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,15 +643,38 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
// validate values
|
// validate values
|
||||||
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
|
||||||
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
|
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
|
// also refresh agent baseline to avoid future negatives
|
||||||
a.initializeDiskIoStats(ioCounters)
|
a.initializeDiskIoStats(ioCounters)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update per-interval snapshot
|
// These properties are calculated differently on different platforms,
|
||||||
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
|
// 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
|
// Update global fsStats baseline for cross-interval correctness
|
||||||
stats.Time = now
|
stats.Time = now
|
||||||
@@ -617,20 +684,40 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
|
|||||||
stats.DiskWritePs = writeMbPerSecond
|
stats.DiskWritePs = writeMbPerSecond
|
||||||
stats.DiskReadBytes = diskIORead
|
stats.DiskReadBytes = diskIORead
|
||||||
stats.DiskWriteBytes = diskIOWrite
|
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 {
|
if stats.Root {
|
||||||
systemStats.DiskReadPs = stats.DiskReadPs
|
systemStats.DiskReadPs = stats.DiskReadPs
|
||||||
systemStats.DiskWritePs = stats.DiskWritePs
|
systemStats.DiskWritePs = stats.DiskWritePs
|
||||||
systemStats.DiskIO[0] = diskIORead
|
systemStats.DiskIO[0] = diskIORead
|
||||||
systemStats.DiskIO[1] = diskIOWrite
|
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 /
|
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
|
||||||
func (a *Agent) getRootMountPoint() string {
|
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
|
// 1. Check if /etc/os-release contains indicators of an immutable system
|
||||||
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
|
||||||
content := string(osReleaseContent)
|
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) {
|
func TestFindIoDevice(t *testing.T) {
|
||||||
t.Run("matches by device name", func(t *testing.T) {
|
t.Run("matches by device name", func(t *testing.T) {
|
||||||
ioCounters := map[string]disk.IOCountersStat{
|
ioCounters := map[string]disk.IOCountersStat{
|
||||||
|
|||||||
164
agent/docker.go
164
agent/docker.go
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/agent/deltatracker"
|
"github.com/henrygd/beszel/agent/deltatracker"
|
||||||
"github.com/henrygd/beszel/agent/utils"
|
"github.com/henrygd/beszel/agent/utils"
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
|
||||||
"github.com/blang/semver"
|
"github.com/blang/semver"
|
||||||
)
|
)
|
||||||
@@ -52,20 +53,22 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type dockerManager struct {
|
type dockerManager struct {
|
||||||
client *http.Client // Client to query Docker API
|
agent *Agent // Used to propagate system detail changes back to the agent
|
||||||
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
client *http.Client // Client to query Docker API
|
||||||
sem chan struct{} // Semaphore to limit concurrent container requests
|
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
|
||||||
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
sem chan struct{} // Semaphore to limit concurrent container requests
|
||||||
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
|
||||||
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
apiContainerList []*container.ApiInfo // List of containers from Docker API
|
||||||
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
containerStatsMap map[string]*container.Stats // Keeps track of container stats
|
||||||
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
|
||||||
isWindows bool // Whether the Docker Engine API is running on Windows
|
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
|
||||||
buf *bytes.Buffer // Buffer to store and read response bodies
|
dockerVersionChecked bool // Whether a version probe has completed successfully
|
||||||
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
isWindows bool // Whether the Docker Engine API is running on Windows
|
||||||
apiStats *container.ApiStats // Reusable API stats object
|
buf *bytes.Buffer // Buffer to store and read response bodies
|
||||||
excludeContainers []string // Patterns to exclude containers by name
|
decoder *json.Decoder // Reusable JSON decoder that reads from buf
|
||||||
usingPodman bool // Whether the Docker Engine API is running on Podman
|
apiStats *container.ApiStats // Reusable API stats object
|
||||||
|
excludeContainers []string // Patterns to exclude containers by name
|
||||||
|
usingPodman bool // Whether the Docker Engine API is running on Podman
|
||||||
|
|
||||||
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
// Cache-time-aware tracking for CPU stats (similar to cpu.go)
|
||||||
// Maps cache time intervals to container-specific CPU usage tracking
|
// Maps cache time intervals to container-specific CPU usage tracking
|
||||||
@@ -78,7 +81,6 @@ type dockerManager struct {
|
|||||||
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
|
||||||
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
|
||||||
retrySleep func(time.Duration)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
|
||||||
@@ -87,6 +89,14 @@ type userAgentRoundTripper struct {
|
|||||||
userAgent string
|
userAgent string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dockerVersionResponse contains the /version fields used for engine checks.
|
||||||
|
type dockerVersionResponse struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
Components []struct {
|
||||||
|
Name string `json:"Name"`
|
||||||
|
} `json:"Components"`
|
||||||
|
}
|
||||||
|
|
||||||
// RoundTrip implements the http.RoundTripper interface
|
// RoundTrip implements the http.RoundTripper interface
|
||||||
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
req.Header.Set("User-Agent", u.userAgent)
|
req.Header.Set("User-Agent", u.userAgent)
|
||||||
@@ -134,7 +144,14 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.isWindows = strings.Contains(resp.Header.Get("Server"), "windows")
|
// Detect Podman and Windows from Server header
|
||||||
|
serverHeader := resp.Header.Get("Server")
|
||||||
|
if !dm.usingPodman && detectPodmanFromHeader(serverHeader) {
|
||||||
|
dm.setIsPodman()
|
||||||
|
}
|
||||||
|
dm.isWindows = strings.Contains(serverHeader, "windows")
|
||||||
|
|
||||||
|
dm.ensureDockerVersionChecked()
|
||||||
|
|
||||||
containersLength := len(dm.apiContainerList)
|
containersLength := len(dm.apiContainerList)
|
||||||
|
|
||||||
@@ -588,7 +605,7 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Creates a new http client for Docker or Podman API
|
// Creates a new http client for Docker or Podman API
|
||||||
func newDockerManager() *dockerManager {
|
func newDockerManager(agent *Agent) *dockerManager {
|
||||||
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
|
||||||
if exists {
|
if exists {
|
||||||
// return nil if set to empty string
|
// return nil if set to empty string
|
||||||
@@ -654,6 +671,7 @@ func newDockerManager() *dockerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
manager := &dockerManager{
|
manager := &dockerManager{
|
||||||
|
agent: agent,
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
Transport: userAgentTransport,
|
Transport: userAgentTransport,
|
||||||
@@ -671,51 +689,54 @@ func newDockerManager() *dockerManager {
|
|||||||
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||||
retrySleep: time.Sleep,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If using podman, return client
|
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
|
||||||
if strings.Contains(dockerHost, "podman") {
|
// retry after the first successful /containers/json request.
|
||||||
manager.usingPodman = true
|
_, _ = manager.checkDockerVersion()
|
||||||
manager.goodDockerVersion = true
|
|
||||||
return manager
|
|
||||||
}
|
|
||||||
|
|
||||||
// run version check in goroutine to avoid blocking (server may not be ready and requires retries)
|
|
||||||
go manager.checkDockerVersion()
|
|
||||||
|
|
||||||
// give version check a chance to complete before returning
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
|
|
||||||
return manager
|
return manager
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
// checkDockerVersion checks Docker version and sets goodDockerVersion if at least 25.0.0.
|
||||||
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
// Versions before 25.0.0 have a bug with one-shot which requires all requests to be made in one batch.
|
||||||
func (dm *dockerManager) checkDockerVersion() {
|
func (dm *dockerManager) checkDockerVersion() (bool, error) {
|
||||||
var err error
|
resp, err := dm.client.Get("http://localhost/version")
|
||||||
var resp *http.Response
|
if err != nil {
|
||||||
var versionInfo struct {
|
return false, err
|
||||||
Version string `json:"Version"`
|
|
||||||
}
|
}
|
||||||
const versionMaxTries = 2
|
if resp.StatusCode != http.StatusOK {
|
||||||
for i := 1; i <= versionMaxTries; i++ {
|
status := resp.Status
|
||||||
resp, err = dm.client.Get("http://localhost/version")
|
resp.Body.Close()
|
||||||
if err == nil && resp.StatusCode == http.StatusOK {
|
return false, fmt.Errorf("docker version request failed: %s", status)
|
||||||
break
|
|
||||||
}
|
|
||||||
if resp != nil {
|
|
||||||
resp.Body.Close()
|
|
||||||
}
|
|
||||||
if i < versionMaxTries {
|
|
||||||
slog.Debug("Failed to get Docker version; retrying", "attempt", i, "err", err, "response", resp)
|
|
||||||
dm.retrySleep(5 * time.Second)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
|
||||||
|
var versionInfo dockerVersionResponse
|
||||||
|
serverHeader := resp.Header.Get("Server")
|
||||||
|
if err := dm.decode(resp, &versionInfo); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dm.applyDockerVersionInfo(serverHeader, &versionInfo)
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDockerVersionChecked retries the version probe after a successful
|
||||||
|
// container list request.
|
||||||
|
func (dm *dockerManager) ensureDockerVersionChecked() {
|
||||||
|
if dm.dockerVersionChecked {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := dm.decode(resp, &versionInfo); err != nil {
|
if _, err := dm.checkDockerVersion(); err != nil {
|
||||||
|
slog.Debug("Failed to get Docker version", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyDockerVersionInfo updates version-dependent behavior from engine metadata.
|
||||||
|
func (dm *dockerManager) applyDockerVersionInfo(serverHeader string, versionInfo *dockerVersionResponse) {
|
||||||
|
if detectPodmanEngine(serverHeader, versionInfo) {
|
||||||
|
dm.setIsPodman()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
// if version > 24, one-shot works correctly and we can limit concurrent operations
|
||||||
@@ -941,3 +962,46 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
|
|||||||
func (dm *dockerManager) IsPodman() bool {
|
func (dm *dockerManager) IsPodman() bool {
|
||||||
return dm.usingPodman
|
return dm.usingPodman
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setIsPodman sets the manager to Podman mode and updates system details accordingly.
|
||||||
|
func (dm *dockerManager) setIsPodman() {
|
||||||
|
if dm.usingPodman {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dm.usingPodman = true
|
||||||
|
dm.goodDockerVersion = true
|
||||||
|
dm.dockerVersionChecked = true
|
||||||
|
// keep system details updated - this may be detected late if server isn't ready when
|
||||||
|
// agent starts, so make sure we notify the hub if this happens later.
|
||||||
|
if dm.agent != nil {
|
||||||
|
dm.agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromHeader identifies Podman from the Docker API server header.
|
||||||
|
func detectPodmanFromHeader(server string) bool {
|
||||||
|
return strings.HasPrefix(server, "Libpod")
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanFromVersion identifies Podman from the version payload.
|
||||||
|
func detectPodmanFromVersion(versionInfo *dockerVersionResponse) bool {
|
||||||
|
if versionInfo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, component := range versionInfo.Components {
|
||||||
|
if strings.HasPrefix(component.Name, "Podman") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectPodmanEngine checks both header and version metadata for Podman.
|
||||||
|
func detectPodmanEngine(serverHeader string, versionInfo *dockerVersionResponse) bool {
|
||||||
|
if detectPodmanFromHeader(serverHeader) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return detectPodmanFromVersion(versionInfo)
|
||||||
|
}
|
||||||
|
|||||||
@@ -539,59 +539,53 @@ func TestDockerManagerCreation(t *testing.T) {
|
|||||||
|
|
||||||
func TestCheckDockerVersion(t *testing.T) {
|
func TestCheckDockerVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
responses []struct {
|
statusCode int
|
||||||
statusCode int
|
body string
|
||||||
body string
|
server string
|
||||||
}
|
expectSuccess bool
|
||||||
expectedGood bool
|
expectedGood bool
|
||||||
expectedRequests int
|
expectedPodman bool
|
||||||
|
expectError bool
|
||||||
|
expectedRequest string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "200 with good version on first try",
|
name: "good docker version",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"25.0.1"}`,
|
||||||
body string
|
expectSuccess: true,
|
||||||
}{
|
expectedGood: true,
|
||||||
{http.StatusOK, `{"Version":"25.0.1"}`},
|
expectedPodman: false,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: true,
|
|
||||||
expectedRequests: 1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "200 with old version on first try",
|
name: "old docker version",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"24.0.7"}`,
|
||||||
body string
|
expectSuccess: true,
|
||||||
}{
|
expectedGood: false,
|
||||||
{http.StatusOK, `{"Version":"24.0.7"}`},
|
expectedPodman: false,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: false,
|
|
||||||
expectedRequests: 1,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-200 then 200 with good version",
|
name: "podman from server header",
|
||||||
responses: []struct {
|
statusCode: http.StatusOK,
|
||||||
statusCode int
|
body: `{"Version":"5.5.0"}`,
|
||||||
body string
|
server: "Libpod/5.5.0",
|
||||||
}{
|
expectSuccess: true,
|
||||||
{http.StatusServiceUnavailable, `"not ready"`},
|
expectedGood: true,
|
||||||
{http.StatusOK, `{"Version":"25.1.0"}`},
|
expectedPodman: true,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: true,
|
|
||||||
expectedRequests: 2,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "non-200 on all retries",
|
name: "non-200 response",
|
||||||
responses: []struct {
|
statusCode: http.StatusServiceUnavailable,
|
||||||
statusCode int
|
body: `"not ready"`,
|
||||||
body string
|
expectSuccess: false,
|
||||||
}{
|
expectedGood: false,
|
||||||
{http.StatusInternalServerError, `"error"`},
|
expectedPodman: false,
|
||||||
{http.StatusUnauthorized, `"error"`},
|
expectError: true,
|
||||||
},
|
expectedRequest: "/version",
|
||||||
expectedGood: false,
|
|
||||||
expectedRequests: 2,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -599,13 +593,13 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
requestCount := 0
|
requestCount := 0
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
idx := requestCount
|
|
||||||
requestCount++
|
requestCount++
|
||||||
if idx >= len(tt.responses) {
|
assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath())
|
||||||
idx = len(tt.responses) - 1
|
if tt.server != "" {
|
||||||
|
w.Header().Set("Server", tt.server)
|
||||||
}
|
}
|
||||||
w.WriteHeader(tt.responses[idx].statusCode)
|
w.WriteHeader(tt.statusCode)
|
||||||
fmt.Fprint(w, tt.responses[idx].body)
|
fmt.Fprint(w, tt.body)
|
||||||
}))
|
}))
|
||||||
defer server.Close()
|
defer server.Close()
|
||||||
|
|
||||||
@@ -617,17 +611,24 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
retrySleep: func(time.Duration) {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.checkDockerVersion()
|
success, err := dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectSuccess, success)
|
||||||
|
assert.Equal(t, tt.expectSuccess, dm.dockerVersionChecked)
|
||||||
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
assert.Equal(t, tt.expectedRequests, requestCount)
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCount)
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
t.Run("request error on all retries", func(t *testing.T) {
|
t.Run("request error", func(t *testing.T) {
|
||||||
requestCount := 0
|
requestCount := 0
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
client: &http.Client{
|
client: &http.Client{
|
||||||
@@ -638,16 +639,171 @@ func TestCheckDockerVersion(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
retrySleep: func(time.Duration) {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dm.checkDockerVersion()
|
success, err := dm.checkDockerVersion()
|
||||||
|
|
||||||
|
assert.False(t, success)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.False(t, dm.dockerVersionChecked)
|
||||||
assert.False(t, dm.goodDockerVersion)
|
assert.False(t, dm.goodDockerVersion)
|
||||||
assert.Equal(t, 2, requestCount)
|
assert.False(t, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCount)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newDockerManagerForVersionTest creates a dockerManager wired to a test server.
|
||||||
|
func newDockerManagerForVersionTest(server *httptest.Server) *dockerManager {
|
||||||
|
return &dockerManager{
|
||||||
|
client: &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
DialContext: func(_ context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
return net.Dial(network, server.Listener.Addr().String())
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containerStatsMap: make(map[string]*container.Stats),
|
||||||
|
lastCpuContainer: make(map[uint16]map[string]uint64),
|
||||||
|
lastCpuSystem: make(map[uint16]map[string]uint64),
|
||||||
|
lastCpuReadTime: make(map[uint16]map[string]time.Time),
|
||||||
|
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
|
||||||
|
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDockerStatsChecksDockerVersionAfterContainerList(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
containerServer string
|
||||||
|
versionServer string
|
||||||
|
versionBody string
|
||||||
|
expectedGood bool
|
||||||
|
expectedPodman bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "200 with good version on first try",
|
||||||
|
versionBody: `{"Version":"25.0.1"}`,
|
||||||
|
expectedGood: true,
|
||||||
|
expectedPodman: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "200 with old version on first try",
|
||||||
|
versionBody: `{"Version":"24.0.7"}`,
|
||||||
|
expectedGood: false,
|
||||||
|
expectedPodman: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "podman detected from server header",
|
||||||
|
containerServer: "Libpod/5.5.0",
|
||||||
|
expectedGood: true,
|
||||||
|
expectedPodman: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
requestCounts := map[string]int{}
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCounts[r.URL.EscapedPath()]++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/containers/json":
|
||||||
|
if tt.containerServer != "" {
|
||||||
|
w.Header().Set("Server", tt.containerServer)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case "/version":
|
||||||
|
if tt.versionServer != "" {
|
||||||
|
w.Header().Set("Server", tt.versionServer)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, tt.versionBody)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
|
||||||
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 1, requestCounts["/containers/json"])
|
||||||
|
if tt.expectedPodman {
|
||||||
|
assert.Equal(t, 0, requestCounts["/version"])
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.Equal(t, tt.expectedGood, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, tt.expectedPodman, dm.usingPodman)
|
||||||
|
assert.Equal(t, 2, requestCounts["/containers/json"])
|
||||||
|
if tt.expectedPodman {
|
||||||
|
assert.Equal(t, 0, requestCounts["/version"])
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDockerStatsRetriesVersionCheckUntilSuccess(t *testing.T) {
|
||||||
|
requestCounts := map[string]int{}
|
||||||
|
versionStatuses := []int{http.StatusServiceUnavailable, http.StatusOK}
|
||||||
|
versionBodies := []string{`"not ready"`, `{"Version":"25.1.0"}`}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCounts[r.URL.EscapedPath()]++
|
||||||
|
switch r.URL.EscapedPath() {
|
||||||
|
case "/containers/json":
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `[]`)
|
||||||
|
case "/version":
|
||||||
|
idx := requestCounts["/version"] - 1
|
||||||
|
if idx >= len(versionStatuses) {
|
||||||
|
idx = len(versionStatuses) - 1
|
||||||
|
}
|
||||||
|
w.WriteHeader(versionStatuses[idx])
|
||||||
|
fmt.Fprint(w, versionBodies[idx])
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected path: %s", r.URL.EscapedPath())
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
dm := newDockerManagerForVersionTest(server)
|
||||||
|
|
||||||
|
stats, err := dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.False(t, dm.dockerVersionChecked)
|
||||||
|
assert.False(t, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, 1, requestCounts["/version"])
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.True(t, dm.dockerVersionChecked)
|
||||||
|
assert.True(t, dm.goodDockerVersion)
|
||||||
|
assert.Equal(t, 2, requestCounts["/containers/json"])
|
||||||
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
|
|
||||||
|
stats, err = dm.getDockerStats(defaultCacheTimeMs)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, stats)
|
||||||
|
assert.Equal(t, 3, requestCounts["/containers/json"])
|
||||||
|
assert.Equal(t, 2, requestCounts["/version"])
|
||||||
|
}
|
||||||
|
|
||||||
func TestCycleCpuDeltas(t *testing.T) {
|
func TestCycleCpuDeltas(t *testing.T) {
|
||||||
dm := &dockerManager{
|
dm := &dockerManager{
|
||||||
lastCpuContainer: map[uint16]map[string]uint64{
|
lastCpuContainer: map[uint16]map[string]uint64{
|
||||||
|
|||||||
11
agent/gpu.go
11
agent/gpu.go
@@ -542,7 +542,7 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
|
|||||||
return map[collectorSource]collectorDefinition{
|
return map[collectorSource]collectorDefinition{
|
||||||
collectorSourceNVML: {
|
collectorSourceNVML: {
|
||||||
group: collectorGroupNvidia,
|
group: collectorGroupNvidia,
|
||||||
available: caps.hasNvidiaSmi,
|
available: true,
|
||||||
start: func(_ func()) bool {
|
start: func(_ func()) bool {
|
||||||
return gm.startNvmlCollector()
|
return gm.startNvmlCollector()
|
||||||
},
|
},
|
||||||
@@ -734,9 +734,6 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
}
|
}
|
||||||
var gm GPUManager
|
var gm GPUManager
|
||||||
caps := gm.discoverGpuCapabilities()
|
caps := gm.discoverGpuCapabilities()
|
||||||
if !hasAnyGpuCollector(caps) {
|
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
|
||||||
}
|
|
||||||
gm.GpuDataMap = make(map[string]*system.GPUData)
|
gm.GpuDataMap = make(map[string]*system.GPUData)
|
||||||
|
|
||||||
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
|
||||||
@@ -745,7 +742,7 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// if GPU_COLLECTOR is set, start user-defined collectors.
|
// Respect explicit collector selection before capability auto-detection.
|
||||||
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
if collectorConfig, ok := utils.GetEnv("GPU_COLLECTOR"); ok && strings.TrimSpace(collectorConfig) != "" {
|
||||||
priorities := parseCollectorPriority(collectorConfig)
|
priorities := parseCollectorPriority(collectorConfig)
|
||||||
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
if gm.startCollectorsByPriority(priorities, caps) == 0 {
|
||||||
@@ -754,6 +751,10 @@ func NewGPUManager() (*GPUManager, error) {
|
|||||||
return &gm, nil
|
return &gm, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hasAnyGpuCollector(caps) {
|
||||||
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
|
||||||
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
// auto-detect and start collectors when GPU_COLLECTOR is unset.
|
||||||
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
|
||||||
return nil, fmt.Errorf(noGPUFoundMsg)
|
return nil, fmt.Errorf(noGPUFoundMsg)
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ func (gm *GPUManager) updateAmdGpuData(cardPath string) bool {
|
|||||||
func readSysfsFloat(path string) (float64, error) {
|
func readSysfsFloat(path string) (float64, error) {
|
||||||
val, err := utils.ReadStringFileLimited(path, 64)
|
val, err := utils.ReadStringFileLimited(path, 64)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Debug("Failed to read sysfs value", "path", path, "error", err)
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return strconv.ParseFloat(val, 64)
|
return strconv.ParseFloat(val, 64)
|
||||||
|
|||||||
@@ -1461,6 +1461,25 @@ func TestNewGPUManagerConfiguredCollectorsMustStart(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCollectorDefinitionsNvmlDoesNotRequireNvidiaSmi(t *testing.T) {
|
||||||
|
gm := &GPUManager{}
|
||||||
|
definitions := gm.collectorDefinitions(gpuCapabilities{})
|
||||||
|
require.Contains(t, definitions, collectorSourceNVML)
|
||||||
|
assert.True(t, definitions[collectorSourceNVML].available)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewGPUManagerConfiguredNvmlBypassesCapabilityGate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("PATH", dir)
|
||||||
|
t.Setenv("BESZEL_AGENT_GPU_COLLECTOR", "nvml")
|
||||||
|
|
||||||
|
gm, err := NewGPUManager()
|
||||||
|
require.Nil(t, gm)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "no configured GPU collectors are available")
|
||||||
|
assert.NotContains(t, err.Error(), noGPUFoundMsg)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
func TestNewGPUManagerJetsonIgnoresCollectorConfig(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
t.Setenv("PATH", dir)
|
t.Setenv("PATH", dir)
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
|
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -19,13 +19,20 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/sensors"
|
"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 {
|
type SensorConfig struct {
|
||||||
context context.Context
|
context context.Context
|
||||||
sensors map[string]struct{}
|
sensors map[string]struct{}
|
||||||
primarySensor string
|
primarySensor string
|
||||||
|
timeout time.Duration
|
||||||
isBlacklist bool
|
isBlacklist bool
|
||||||
hasWildcards bool
|
hasWildcards bool
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
|
firstRun bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Agent) newSensorConfig() *SensorConfig {
|
func (a *Agent) newSensorConfig() *SensorConfig {
|
||||||
@@ -33,25 +40,29 @@ func (a *Agent) newSensorConfig() *SensorConfig {
|
|||||||
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
|
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
|
||||||
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
|
||||||
skipCollection := sensorsSet && sensorsEnvVal == ""
|
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
|
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
|
||||||
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
|
// 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{
|
config := &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: primarySensor,
|
primarySensor: primarySensor,
|
||||||
|
timeout: timeout,
|
||||||
skipCollection: skipCollection,
|
skipCollection: skipCollection,
|
||||||
|
firstRun: true,
|
||||||
sensors: make(map[string]struct{}),
|
sensors: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +178,14 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
|||||||
err error
|
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)
|
resultCh := make(chan result, 1)
|
||||||
go func() {
|
go func() {
|
||||||
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
temps, err := a.getTempsWithPanicRecovery(getTemps)
|
||||||
@@ -176,7 +195,7 @@ func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureS
|
|||||||
select {
|
select {
|
||||||
case res := <-resultCh:
|
case res := <-resultCh:
|
||||||
return res.temps, res.err
|
return res.temps, res.err
|
||||||
case <-time.After(temperatureFetchTimeout):
|
case <-time.After(timeout):
|
||||||
return nil, errTemperatureFetchTimeout
|
return nil, errTemperatureFetchTimeout
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
primarySensor string
|
primarySensor string
|
||||||
sysSensors string
|
sysSensors string
|
||||||
sensors string
|
sensors string
|
||||||
|
sensorsTimeout string
|
||||||
skipCollection bool
|
skipCollection bool
|
||||||
expectedConfig *SensorConfig
|
expectedConfig *SensorConfig
|
||||||
}{
|
}{
|
||||||
@@ -179,12 +180,37 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
skipCollection: 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",
|
name: "Explicitly set to empty string",
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
@@ -194,6 +220,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "",
|
primarySensor: "",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
@@ -208,6 +235,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{},
|
sensors: map[string]struct{}{},
|
||||||
isBlacklist: false,
|
isBlacklist: false,
|
||||||
hasWildcards: false,
|
hasWildcards: false,
|
||||||
@@ -221,6 +249,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -237,6 +266,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -253,6 +283,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_*": {},
|
"cpu_*": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -269,6 +300,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_*": {},
|
"cpu_*": {},
|
||||||
"gpu_temp": {},
|
"gpu_temp": {},
|
||||||
@@ -284,6 +316,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
sensors: "cpu_temp",
|
sensors: "cpu_temp",
|
||||||
expectedConfig: &SensorConfig{
|
expectedConfig: &SensorConfig{
|
||||||
primarySensor: "cpu_temp",
|
primarySensor: "cpu_temp",
|
||||||
|
timeout: 2 * time.Second,
|
||||||
sensors: map[string]struct{}{
|
sensors: map[string]struct{}{
|
||||||
"cpu_temp": {},
|
"cpu_temp": {},
|
||||||
},
|
},
|
||||||
@@ -295,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
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
|
// Check primary sensor
|
||||||
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
|
||||||
@@ -314,6 +347,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
|
|||||||
// Check flags
|
// Check flags
|
||||||
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
|
||||||
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
|
||||||
|
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
|
||||||
|
|
||||||
// Check context
|
// Check context
|
||||||
if tt.sysSensors != "" {
|
if tt.sysSensors != "" {
|
||||||
@@ -333,12 +367,14 @@ func TestNewSensorConfig(t *testing.T) {
|
|||||||
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
|
||||||
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
|
||||||
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
|
||||||
|
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
|
||||||
|
|
||||||
agent := &Agent{}
|
agent := &Agent{}
|
||||||
result := agent.newSensorConfig()
|
result := agent.newSensorConfig()
|
||||||
|
|
||||||
// Verify results
|
// Verify results
|
||||||
assert.Equal(t, "test_primary", result.primarySensor)
|
assert.Equal(t, "test_primary", result.primarySensor)
|
||||||
|
assert.Equal(t, 7*time.Second, result.timeout)
|
||||||
assert.NotNil(t, result.sensors)
|
assert.NotNil(t, result.sensors)
|
||||||
assert.Equal(t, 3, len(result.sensors))
|
assert.Equal(t, 3, len(result.sensors))
|
||||||
assert.True(t, result.hasWildcards)
|
assert.True(t, result.hasWildcards)
|
||||||
@@ -532,15 +568,10 @@ func TestGetTempsWithTimeout(t *testing.T) {
|
|||||||
agent := &Agent{
|
agent := &Agent{
|
||||||
sensorConfig: &SensorConfig{
|
sensorConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
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) {
|
t.Run("returns temperatures before timeout", func(t *testing.T) {
|
||||||
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
|
||||||
@@ -567,15 +598,13 @@ func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
|
|||||||
systemInfo: system.Info{DashboardTemp: 99},
|
systemInfo: system.Info{DashboardTemp: 99},
|
||||||
sensorConfig: &SensorConfig{
|
sensorConfig: &SensorConfig{
|
||||||
context: context.Background(),
|
context: context.Background(),
|
||||||
|
timeout: 10 * time.Millisecond,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
originalTimeout := temperatureFetchTimeout
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
temperatureFetchTimeout = originalTimeout
|
|
||||||
getSensorTemps = sensors.TemperaturesWithContext
|
getSensorTemps = sensors.TemperaturesWithContext
|
||||||
})
|
})
|
||||||
temperatureFetchTimeout = 10 * time.Millisecond
|
|
||||||
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
|
||||||
time.Sleep(50 * time.Millisecond)
|
time.Sleep(50 * time.Millisecond)
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ func (a *Agent) handleSSHRequest(w io.Writer, req *common.HubRequest[cbor.RawMes
|
|||||||
|
|
||||||
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
// handleLegacyStats serves the legacy one-shot stats payload for older hubs
|
||||||
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
func (a *Agent) handleLegacyStats(w io.Writer, hubVersion semver.Version) error {
|
||||||
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: 60_000})
|
stats := a.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
return a.writeToSession(w, stats, hubVersion)
|
return a.writeToSession(w, stats, hubVersion)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,12 +25,15 @@ import (
|
|||||||
// SmartManager manages data collection for SMART devices
|
// SmartManager manages data collection for SMART devices
|
||||||
type SmartManager struct {
|
type SmartManager struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
SmartDataMap map[string]*smart.SmartData
|
SmartDataMap map[string]*smart.SmartData
|
||||||
SmartDevices []*DeviceInfo
|
SmartDevices []*DeviceInfo
|
||||||
refreshMutex sync.Mutex
|
refreshMutex sync.Mutex
|
||||||
lastScanTime time.Time
|
lastScanTime time.Time
|
||||||
smartctlPath string
|
smartctlPath string
|
||||||
excludedDevices map[string]struct{}
|
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 {
|
type scanOutput struct {
|
||||||
@@ -1033,6 +1036,52 @@ func parseScsiGigabytesProcessed(value string) int64 {
|
|||||||
return parsed
|
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
|
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
|
||||||
// Returns hasValidData and exitStatus
|
// Returns hasValidData and exitStatus
|
||||||
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
|
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.SerialNumber = data.SerialNumber
|
||||||
smartData.FirmwareVersion = data.FirmwareVersion
|
smartData.FirmwareVersion = data.FirmwareVersion
|
||||||
smartData.Capacity = data.UserCapacity.Bytes
|
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.Temperature = data.NVMeSmartHealthInformationLog.Temperature
|
||||||
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
|
||||||
smartData.DiskName = data.Device.Name
|
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"
|
"os"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel"
|
||||||
"github.com/henrygd/beszel/agent/battery"
|
"github.com/henrygd/beszel/agent/battery"
|
||||||
@@ -23,13 +22,6 @@ import (
|
|||||||
"github.com/shirou/gopsutil/v4/mem"
|
"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
|
// Sets initial / non-changing values about the host system
|
||||||
func (a *Agent) refreshSystemDetails() {
|
func (a *Agent) refreshSystemDetails() {
|
||||||
a.systemInfo.AgentVersion = beszel.Version
|
a.systemInfo.AgentVersion = beszel.Version
|
||||||
@@ -115,6 +107,26 @@ func (a *Agent) refreshSystemDetails() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// attachSystemDetails returns details only for fresh default-interval responses.
|
||||||
|
func (a *Agent) attachSystemDetails(data *system.CombinedData, cacheTimeMs uint16, includeRequested bool) *system.CombinedData {
|
||||||
|
if cacheTimeMs != defaultDataCacheTimeMs || (!includeRequested && !a.detailsDirty) {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// copy data to avoid adding details to the original cached struct
|
||||||
|
response := *data
|
||||||
|
response.Details = &a.systemDetails
|
||||||
|
a.detailsDirty = false
|
||||||
|
return &response
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSystemDetails applies a mutation to the static details payload and marks
|
||||||
|
// it for inclusion on the next fresh default-interval response.
|
||||||
|
func (a *Agent) updateSystemDetails(updateFunc func(details *system.Details)) {
|
||||||
|
updateFunc(&a.systemDetails)
|
||||||
|
a.detailsDirty = true
|
||||||
|
}
|
||||||
|
|
||||||
// Returns current info, stats about the host system
|
// Returns current info, stats about the host system
|
||||||
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
|
||||||
var systemStats system.Stats
|
var systemStats system.Stats
|
||||||
|
|||||||
61
agent/system_test.go
Normal file
61
agent/system_test.go
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/common"
|
||||||
|
"github.com/henrygd/beszel/internal/entities/system"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGatherStatsDoesNotAttachDetailsToCachedRequests(t *testing.T) {
|
||||||
|
agent := &Agent{
|
||||||
|
cache: NewSystemDataCache(),
|
||||||
|
systemDetails: system.Details{Hostname: "updated-host", Podman: true},
|
||||||
|
detailsDirty: true,
|
||||||
|
}
|
||||||
|
cached := &system.CombinedData{
|
||||||
|
Info: system.Info{Hostname: "cached-host"},
|
||||||
|
}
|
||||||
|
agent.cache.Set(cached, defaultDataCacheTimeMs)
|
||||||
|
|
||||||
|
response := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
|
||||||
|
assert.Same(t, cached, response)
|
||||||
|
assert.Nil(t, response.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "cached-host", response.Info.Hostname)
|
||||||
|
assert.Nil(t, cached.Details)
|
||||||
|
|
||||||
|
secondResponse := agent.gatherStats(common.DataRequestOptions{CacheTimeMs: defaultDataCacheTimeMs})
|
||||||
|
assert.Same(t, cached, secondResponse)
|
||||||
|
assert.Nil(t, secondResponse.Details)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateSystemDetailsMarksDetailsDirty(t *testing.T) {
|
||||||
|
agent := &Agent{}
|
||||||
|
|
||||||
|
agent.updateSystemDetails(func(details *system.Details) {
|
||||||
|
details.Hostname = "updated-host"
|
||||||
|
details.Podman = true
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
assert.Equal(t, "updated-host", agent.systemDetails.Hostname)
|
||||||
|
assert.True(t, agent.systemDetails.Podman)
|
||||||
|
|
||||||
|
original := &system.CombinedData{}
|
||||||
|
realTimeResponse := agent.attachSystemDetails(original, 1000, true)
|
||||||
|
assert.Same(t, original, realTimeResponse)
|
||||||
|
assert.Nil(t, realTimeResponse.Details)
|
||||||
|
assert.True(t, agent.detailsDirty)
|
||||||
|
|
||||||
|
response := agent.attachSystemDetails(original, defaultDataCacheTimeMs, false)
|
||||||
|
require.NotNil(t, response.Details)
|
||||||
|
assert.NotSame(t, original, response)
|
||||||
|
assert.Equal(t, "updated-host", response.Details.Hostname)
|
||||||
|
assert.True(t, response.Details.Podman)
|
||||||
|
assert.False(t, agent.detailsDirty)
|
||||||
|
assert.Nil(t, original.Details)
|
||||||
|
}
|
||||||
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
|
package utils
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
@@ -68,6 +70,9 @@ func ReadStringFileLimited(path string, maxSize int) (string, error) {
|
|||||||
if err != nil && err != io.EOF {
|
if err != nil && err != io.EOF {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
if n < 0 {
|
||||||
|
return "", fmt.Errorf("%s returned negative bytes: %d", path, n)
|
||||||
|
}
|
||||||
return strings.TrimSpace(string(buf[:n])), nil
|
return strings.TrimSpace(string(buf[:n])), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import "github.com/blang/semver"
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// Version is the current version of the application.
|
// Version is the current version of the application.
|
||||||
Version = "0.18.5"
|
Version = "0.18.7"
|
||||||
// AppName is the name of the application.
|
// AppName is the name of the application.
|
||||||
AppName = "beszel"
|
AppName = "beszel"
|
||||||
)
|
)
|
||||||
|
|||||||
11
go.mod
11
go.mod
@@ -5,16 +5,15 @@ go 1.26.1
|
|||||||
require (
|
require (
|
||||||
github.com/blang/semver v3.5.1+incompatible
|
github.com/blang/semver v3.5.1+incompatible
|
||||||
github.com/coreos/go-systemd/v22 v22.7.0
|
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/ebitengine/purego v0.10.0
|
||||||
github.com/fxamacker/cbor/v2 v2.9.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gliderlabs/ssh v0.3.8
|
github.com/gliderlabs/ssh v0.3.8
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/lxzan/gws v1.9.1
|
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/dbx v1.12.0
|
||||||
github.com/pocketbase/pocketbase v0.36.7
|
github.com/pocketbase/pocketbase v0.36.8
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2
|
github.com/shirou/gopsutil/v4 v4.26.3
|
||||||
github.com/spf13/cast v1.10.0
|
github.com/spf13/cast v1.10.0
|
||||||
github.com/spf13/cobra v1.10.2
|
github.com/spf13/cobra v1.10.2
|
||||||
github.com/spf13/pflag v1.0.10
|
github.com/spf13/pflag v1.0.10
|
||||||
@@ -23,6 +22,7 @@ require (
|
|||||||
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
|
||||||
golang.org/x/sys v0.42.0
|
golang.org/x/sys v0.42.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
howett.net/plist v1.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
@@ -61,9 +61,8 @@ require (
|
|||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/term v0.41.0 // indirect
|
golang.org/x/term v0.41.0 // indirect
|
||||||
golang.org/x/text v0.35.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/libc v1.70.0 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
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 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
|
||||||
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
|
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=
|
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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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.3 h1:aBX2iw9a7jl5wfHd3bi9LnS5ucoYIy6KcLH9XVF+gig=
|
||||||
github.com/nicholas-fedor/shoutrrr v0.14.1/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
|
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 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
|
||||||
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
|
||||||
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
|
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/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 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
|
||||||
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
|
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.8 h1:gCNqoesZ44saYOD3J7edhi5nDwUWKyQG7boM/kVwz2c=
|
||||||
github.com/pocketbase/pocketbase v0.36.7/go.mod h1:qX4HuVjoKXtEg41fSJVM0JLfGWXbBmHxVv/FaE446r4=
|
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 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
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 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
|
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||||
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
|
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 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
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/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.46.2 h1:gkXQ6R0+AjxFC/fTDaeIVLbNLNrRoOK7YYVz5BKhTcE=
|
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
|
||||||
modernc.org/sqlite v1.46.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
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 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
@@ -302,21 +302,6 @@ func (am *AlertManager) SendShoutrrrAlert(notificationUrl, title, message, link,
|
|||||||
return nil
|
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
|
// setAlertTriggered updates the "triggered" status of an alert record in the database
|
||||||
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
|
func (am *AlertManager) setAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||||
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
|
alertRecord, err := am.hub.FindRecordById("alerts", alert.Id)
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ package alerts
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"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})
|
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
|
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.
|
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
|
||||||
func (am *AlertManager) processPendingAlert(alertID string) {
|
func (am *AlertManager) processPendingAlert(alertID string) {
|
||||||
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)
|
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")
|
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
|
package alerts_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"testing/synctest"
|
"testing/synctest"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,359 +11,9 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
|
||||||
pbTests "github.com/pocketbase/pocketbase/tests"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"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) {
|
func TestAlertsHistory(t *testing.T) {
|
||||||
synctest.Test(t, func(t *testing.T) {
|
synctest.Test(t, func(t *testing.T) {
|
||||||
hub, user := beszelTests.GetHubWithUser(t)
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
|
|||||||
@@ -95,3 +95,7 @@ func (am *AlertManager) RestorePendingStatusAlerts() error {
|
|||||||
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
|
func (am *AlertManager) SetAlertTriggered(alert CachedAlertData, triggered bool) error {
|
||||||
return am.setAlertTriggered(alert, triggered)
|
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 {
|
if err := a.Start(serverConfig); err != nil {
|
||||||
log.Fatal("Failed to start server: ", err)
|
log.Fatal("Failed to start: ", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,7 +494,7 @@ type SmartInfoForNvme struct {
|
|||||||
FirmwareVersion string `json:"firmware_version"`
|
FirmwareVersion string `json:"firmware_version"`
|
||||||
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
// NVMePCIVendor NVMePCIVendor `json:"nvme_pci_vendor"`
|
||||||
// NVMeIEEEOUIIdentifier uint32 `json:"nvme_ieee_oui_identifier"`
|
// 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"`
|
// NVMeUnallocatedCapacity uint64 `json:"nvme_unallocated_capacity"`
|
||||||
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
// NVMeControllerID uint16 `json:"nvme_controller_id"`
|
||||||
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
// NVMeVersion VersionStringInfo `json:"nvme_version"`
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ type Stats struct {
|
|||||||
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
|
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]
|
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..]
|
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.
|
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
|
||||||
@@ -93,10 +95,12 @@ type FsStats struct {
|
|||||||
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
|
||||||
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
|
||||||
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
|
||||||
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
|
||||||
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
|
||||||
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
|
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
|
||||||
MaxDiskWriteBytes uint64 `json:"wbm,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 {
|
type NetIoStats struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package hub
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"github.com/henrygd/beszel/internal/ghupdate"
|
"github.com/henrygd/beszel/internal/ghupdate"
|
||||||
"github.com/henrygd/beszel/internal/hub/config"
|
"github.com/henrygd/beszel/internal/hub/config"
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
"github.com/henrygd/beszel/internal/hub/systems"
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
"github.com/pocketbase/dbx"
|
"github.com/pocketbase/dbx"
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
@@ -25,6 +27,32 @@ type UpdateInfo struct {
|
|||||||
Url string `json:"url"`
|
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
|
// registerMiddlewares registers custom middlewares
|
||||||
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
||||||
// authorizes request with user matching the provided email
|
// authorizes request with user matching the provided email
|
||||||
@@ -33,7 +61,7 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
|||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
|
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 {
|
if err != nil || !isAuthRefresh {
|
||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
@@ -43,13 +71,13 @@ func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
|
|||||||
return e.Next()
|
return e.Next()
|
||||||
}
|
}
|
||||||
// authenticate with trusted header
|
// 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 {
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
return authorizeRequestWithEmail(e, autoLogin)
|
return authorizeRequestWithEmail(e, autoLogin)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// authenticate with trusted header
|
// 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 {
|
se.Router.BindFunc(func(e *core.RequestEvent) error {
|
||||||
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
|
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("/info", h.getInfo)
|
||||||
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
|
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
|
||||||
// check for updates
|
// check for updates
|
||||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||||
var updateInfo UpdateInfo
|
var updateInfo UpdateInfo
|
||||||
apiAuth.GET("/update", updateInfo.getUpdate)
|
apiAuth.GET("/update", updateInfo.getUpdate)
|
||||||
}
|
}
|
||||||
// send test notification
|
// send test notification
|
||||||
apiAuth.POST("/test-notification", h.SendTestNotification)
|
apiAuth.POST("/test-notification", h.SendTestNotification)
|
||||||
// heartbeat status and test
|
// heartbeat status and test
|
||||||
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
|
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
|
||||||
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
|
apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
|
||||||
// get config.yml content
|
// get config.yml content
|
||||||
apiAuth.GET("/config-yaml", config.GetYamlConfig)
|
apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
|
||||||
// handle agent websocket connection
|
// handle agent websocket connection
|
||||||
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
|
||||||
// get or create universal tokens
|
// get or create universal tokens
|
||||||
apiAuth.GET("/universal-token", h.getUniversalToken)
|
apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
|
||||||
// update / delete user alerts
|
// update / delete user alerts
|
||||||
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
|
||||||
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
|
||||||
// refresh SMART devices for a system
|
// refresh SMART devices for a system
|
||||||
apiAuth.POST("/smart/refresh", h.refreshSmartData)
|
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
|
||||||
// get systemd service details
|
// get systemd service details
|
||||||
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
apiAuth.GET("/systemd/info", h.getSystemdInfo)
|
||||||
// /containers routes
|
// /containers routes
|
||||||
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
if enabled, _ := utils.GetEnv("CONTAINER_DETAILS"); enabled != "false" {
|
||||||
// get container logs
|
// get container logs
|
||||||
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
apiAuth.GET("/containers/logs", h.getContainerLogs)
|
||||||
// get container info
|
// get container info
|
||||||
@@ -120,7 +148,7 @@ func (h *Hub) getInfo(e *core.RequestEvent) error {
|
|||||||
Key: h.pubKey,
|
Key: h.pubKey,
|
||||||
Version: beszel.Version,
|
Version: beszel.Version,
|
||||||
}
|
}
|
||||||
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
|
if optIn, _ := utils.GetEnv("CHECK_UPDATES"); optIn == "true" {
|
||||||
info.CheckUpdate = true
|
info.CheckUpdate = true
|
||||||
}
|
}
|
||||||
return e.JSON(http.StatusOK, info)
|
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)
|
// GetUniversalToken handles the universal token API endpoint (create, read, delete)
|
||||||
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
|
||||||
|
if e.Auth.IsSuperuser() {
|
||||||
|
return e.ForbiddenError("Superusers cannot use universal tokens", nil)
|
||||||
|
}
|
||||||
|
|
||||||
tokenMap := universalTokenMap.GetMap()
|
tokenMap := universalTokenMap.GetMap()
|
||||||
userID := e.Auth.Id
|
userID := e.Auth.Id
|
||||||
query := e.Request.URL.Query()
|
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
|
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
|
||||||
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
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 {
|
if h.hb == nil {
|
||||||
return e.JSON(http.StatusOK, map[string]any{
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
@@ -266,9 +295,6 @@ func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
|
|||||||
|
|
||||||
// testHeartbeat triggers a single heartbeat ping and returns the result
|
// testHeartbeat triggers a single heartbeat ping and returns the result
|
||||||
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
|
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 {
|
if h.hb == nil {
|
||||||
return e.JSON(http.StatusOK, map[string]any{
|
return e.JSON(http.StatusOK, map[string]any{
|
||||||
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
|
"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")
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
containerID := e.Request.URL.Query().Get("container")
|
containerID := e.Request.URL.Query().Get("container")
|
||||||
|
|
||||||
if systemID == "" || containerID == "" {
|
if systemID == "" || containerID == "" || !containerIDPattern.MatchString(containerID) {
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
|
return e.BadRequestError("Invalid system or container parameter", nil)
|
||||||
}
|
|
||||||
if !containerIDPattern.MatchString(containerID) {
|
|
||||||
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
system, err := h.sm.GetSystem(systemID)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil {
|
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
return e.NotFoundError("", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := fetchFunc(system, containerID)
|
data, err := fetchFunc(system, containerID)
|
||||||
if err != nil {
|
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})
|
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")
|
serviceName := query.Get("service")
|
||||||
|
|
||||||
if systemID == "" || serviceName == "" {
|
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)
|
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 {
|
if err != nil {
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
return e.NotFoundError("", err)
|
||||||
}
|
}
|
||||||
details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
details, err := system.FetchSystemdInfoFromAgent(serviceName)
|
||||||
if err != nil {
|
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")
|
e.Response.Header().Set("Cache-Control", "public, max-age=60")
|
||||||
return e.JSON(http.StatusOK, map[string]any{"details": details})
|
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 {
|
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
|
||||||
systemID := e.Request.URL.Query().Get("system")
|
systemID := e.Request.URL.Query().Get("system")
|
||||||
if systemID == "" {
|
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)
|
system, err := h.sm.GetSystem(systemID)
|
||||||
if err != nil {
|
if err != nil || !system.HasUser(e.App, e.Auth) {
|
||||||
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
|
return e.NotFoundError("", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch and save SMART devices
|
|
||||||
if err := system.FetchAndSaveSmartDevices(); err != nil {
|
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"})
|
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package hub_test
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -25,33 +26,33 @@ func jsonReader(v any) io.Reader {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApiRoutesAuthentication(t *testing.T) {
|
func TestApiRoutesAuthentication(t *testing.T) {
|
||||||
hub, _ := beszelTests.NewTestHub(t.TempDir())
|
hub, user := beszelTests.GetHubWithUser(t)
|
||||||
defer hub.Cleanup()
|
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()
|
userToken, err := user.NewAuthToken()
|
||||||
require.NoError(t, err, "Failed to create auth token")
|
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{
|
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
|
||||||
"name": "test-system",
|
"name": "test-system",
|
||||||
"users": []string{user.Id},
|
"users": []string{user.Id},
|
||||||
@@ -65,31 +66,6 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
|
|
||||||
scenarios := []beszelTests.ApiScenario{
|
scenarios := []beszelTests.ApiScenario{
|
||||||
// Auth Protected Routes - Should require authentication
|
// 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",
|
Name: "GET /config-yaml - no auth should fail",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
@@ -106,7 +82,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 403,
|
ExpectedStatus: 403,
|
||||||
ExpectedContent: []string{"Requires admin"},
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -136,7 +112,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 403,
|
ExpectedStatus: 403,
|
||||||
ExpectedContent: []string{"Requires admin role"},
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -158,7 +134,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 403,
|
ExpectedStatus: 403,
|
||||||
ExpectedContent: []string{"Requires admin role"},
|
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -202,6 +178,74 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
|
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
|
||||||
TestAppFactory: testAppFactory,
|
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",
|
Name: "POST /user-alerts - no auth should fail",
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
@@ -273,20 +317,59 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Name: "GET /containers/logs - no auth should fail",
|
Name: "GET /containers/logs - no auth should fail",
|
||||||
Method: http.MethodGet,
|
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,
|
ExpectedStatus: 401,
|
||||||
ExpectedContent: []string{"requires valid"},
|
ExpectedContent: []string{"requires valid"},
|
||||||
TestAppFactory: testAppFactory,
|
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",
|
Name: "GET /containers/logs - with auth but missing system param should fail",
|
||||||
Method: http.MethodGet,
|
Method: http.MethodGet,
|
||||||
URL: "/api/beszel/containers/logs?container=test-container",
|
URL: "/api/beszel/containers/logs?container=abababababab",
|
||||||
Headers: map[string]string{
|
Headers: map[string]string{
|
||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 400,
|
ExpectedStatus: 400,
|
||||||
ExpectedContent: []string{"system and container parameters are required"},
|
ExpectedContent: []string{"Invalid", "parameter"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -297,7 +380,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 400,
|
ExpectedStatus: 400,
|
||||||
ExpectedContent: []string{"system and container parameters are required"},
|
ExpectedContent: []string{"Invalid", "parameter"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -308,7 +391,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 404,
|
ExpectedStatus: 404,
|
||||||
ExpectedContent: []string{"system not found"},
|
ExpectedContent: []string{"The requested resource wasn't found."},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -319,7 +402,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 400,
|
ExpectedStatus: 400,
|
||||||
ExpectedContent: []string{"invalid container parameter"},
|
ExpectedContent: []string{"Invalid", "parameter"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -330,7 +413,7 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 400,
|
ExpectedStatus: 400,
|
||||||
ExpectedContent: []string{"invalid container parameter"},
|
ExpectedContent: []string{"Invalid", "parameter"},
|
||||||
TestAppFactory: testAppFactory,
|
TestAppFactory: testAppFactory,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -341,9 +424,114 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"Authorization": userToken,
|
"Authorization": userToken,
|
||||||
},
|
},
|
||||||
ExpectedStatus: 400,
|
ExpectedStatus: 400,
|
||||||
ExpectedContent: []string{"invalid container parameter"},
|
ExpectedContent: []string{"Invalid", "parameter"},
|
||||||
TestAppFactory: testAppFactory,
|
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
|
// Auth Optional Routes - Should work without authentication
|
||||||
{
|
{
|
||||||
@@ -434,13 +622,17 @@ func TestApiRoutesAuthentication(t *testing.T) {
|
|||||||
"systems": []string{system.Id},
|
"systems": []string{system.Id},
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
// this works but diff behavior on prod vs dev.
|
||||||
Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
|
// dev returns 502; prod returns 200 with static html page 404
|
||||||
Method: http.MethodGet,
|
// TODO: align dev and prod behavior and re-enable this test
|
||||||
URL: "/api/beszel/update",
|
// {
|
||||||
ExpectedStatus: 502,
|
// Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
|
||||||
TestAppFactory: testAppFactory,
|
// Method: http.MethodGet,
|
||||||
},
|
// URL: "/api/beszel/update",
|
||||||
|
// NotExpectedContent: []string{"v:", "\"v\":"},
|
||||||
|
// ExpectedStatus: 502,
|
||||||
|
// TestAppFactory: testAppFactory,
|
||||||
|
// },
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, scenario := range scenarios {
|
for _, scenario := range scenarios {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package hub
|
package hub
|
||||||
|
|
||||||
import "github.com/pocketbase/pocketbase/core"
|
import (
|
||||||
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
|
"github.com/pocketbase/pocketbase/core"
|
||||||
|
)
|
||||||
|
|
||||||
type collectionRules struct {
|
type collectionRules struct {
|
||||||
list *string
|
list *string
|
||||||
@@ -22,11 +25,11 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// disable email auth if DISABLE_PASSWORD_AUTH env var is set
|
// 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.Enabled = disablePasswordAuth != "true"
|
||||||
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
usersCollection.PasswordAuth.IdentityFields = []string{"email"}
|
||||||
// allow oauth user creation if USER_CREATION is set
|
// 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'"
|
cr := "@request.context = 'oauth2'"
|
||||||
usersCollection.CreateRule = &cr
|
usersCollection.CreateRule = &cr
|
||||||
} else {
|
} else {
|
||||||
@@ -34,7 +37,7 @@ func setCollectionAuthSettings(app core.App) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// enable mfaOtp mfa if MFA_OTP env var is set
|
// enable mfaOtp mfa if MFA_OTP env var is set
|
||||||
mfaOtp, _ := GetEnv("MFA_OTP")
|
mfaOtp, _ := utils.GetEnv("MFA_OTP")
|
||||||
usersCollection.OTP.Length = 6
|
usersCollection.OTP.Length = 6
|
||||||
superusersCollection.OTP.Length = 6
|
superusersCollection.OTP.Length = 6
|
||||||
usersCollection.OTP.Enabled = mfaOtp == "true"
|
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
|
// When SHARE_ALL_SYSTEMS is enabled, any authenticated user can read
|
||||||
// system-scoped data. Write rules continue to block readonly users.
|
// 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 != \"\""
|
authenticatedRule := "@request.auth.id != \"\""
|
||||||
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"
|
systemsMemberRule := authenticatedRule + " && users.id ?= @request.auth.id"
|
||||||
|
|||||||
@@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
|
|||||||
|
|
||||||
// Returns the current config.yml file as a JSON object
|
// Returns the current config.yml file as a JSON object
|
||||||
func GetYamlConfig(e *core.RequestEvent) error {
|
func GetYamlConfig(e *core.RequestEvent) error {
|
||||||
if e.Auth.GetString("role") != "admin" {
|
|
||||||
return e.ForbiddenError("Requires admin role", nil)
|
|
||||||
}
|
|
||||||
configContent, err := generateYAML(e.App)
|
configContent, err := generateYAML(e.App)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -9,13 +9,13 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/alerts"
|
"github.com/henrygd/beszel/internal/alerts"
|
||||||
"github.com/henrygd/beszel/internal/hub/config"
|
"github.com/henrygd/beszel/internal/hub/config"
|
||||||
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
"github.com/henrygd/beszel/internal/hub/heartbeat"
|
||||||
"github.com/henrygd/beszel/internal/hub/systems"
|
"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/records"
|
||||||
"github.com/henrygd/beszel/internal/users"
|
"github.com/henrygd/beszel/internal/users"
|
||||||
|
|
||||||
@@ -38,8 +38,6 @@ type Hub struct {
|
|||||||
appURL string
|
appURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
|
|
||||||
|
|
||||||
// NewHub creates a new Hub instance with default configuration
|
// NewHub creates a new Hub instance with default configuration
|
||||||
func NewHub(app core.App) *Hub {
|
func NewHub(app core.App) *Hub {
|
||||||
hub := &Hub{App: app}
|
hub := &Hub{App: app}
|
||||||
@@ -47,7 +45,7 @@ func NewHub(app core.App) *Hub {
|
|||||||
hub.um = users.NewUserManager(hub)
|
hub.um = users.NewUserManager(hub)
|
||||||
hub.rm = records.NewRecordManager(hub)
|
hub.rm = records.NewRecordManager(hub)
|
||||||
hub.sm = systems.NewSystemManager(hub)
|
hub.sm = systems.NewSystemManager(hub)
|
||||||
hub.hb = heartbeat.New(app, GetEnv)
|
hub.hb = heartbeat.New(app, utils.GetEnv)
|
||||||
if hub.hb != nil {
|
if hub.hb != nil {
|
||||||
hub.hbStop = make(chan struct{})
|
hub.hbStop = make(chan struct{})
|
||||||
}
|
}
|
||||||
@@ -55,15 +53,6 @@ func NewHub(app core.App) *Hub {
|
|||||||
return 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.
|
// 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.
|
// 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.
|
// However, PB's tests.TestApp is already bootstrapped, generally doesn't serve, but does handle migrations.
|
||||||
@@ -134,7 +123,7 @@ func (h *Hub) initialize(app core.App) error {
|
|||||||
// batch requests (for alerts)
|
// batch requests (for alerts)
|
||||||
settings.Batch.Enabled = true
|
settings.Batch.Enabled = true
|
||||||
// set URL if APP_URL env is set
|
// 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
|
h.appURL = appURL
|
||||||
settings.Meta.AppURL = appURL
|
settings.Meta.AppURL = appURL
|
||||||
}
|
}
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
"github.com/pocketbase/pocketbase/tools/osutils"
|
"github.com/pocketbase/pocketbase/tools/osutils"
|
||||||
)
|
)
|
||||||
@@ -39,7 +36,7 @@ func (rm *responseModifier) RoundTrip(req *http.Request) (*http.Response, error)
|
|||||||
}
|
}
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
// Create a new response with the modified body
|
// 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.Body = io.NopCloser(strings.NewReader(modifiedBody))
|
||||||
resp.ContentLength = int64(len(modifiedBody))
|
resp.ContentLength = int64(len(modifiedBody))
|
||||||
resp.Header.Set("Content-Length", fmt.Sprintf("%d", 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
|
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
|
// startServer sets up the development server for Beszel
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
func (h *Hub) startServer(se *core.ServeEvent) error {
|
||||||
slog.Info("starting server", "appURL", h.appURL)
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
Scheme: "http",
|
Scheme: "http",
|
||||||
Host: "localhost:5173",
|
Host: "localhost:5173",
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ package hub
|
|||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/henrygd/beszel"
|
"github.com/henrygd/beszel/internal/hub/utils"
|
||||||
"github.com/henrygd/beszel/internal/site"
|
"github.com/henrygd/beszel/internal/site"
|
||||||
|
|
||||||
"github.com/pocketbase/pocketbase/apis"
|
"github.com/pocketbase/pocketbase/apis"
|
||||||
@@ -17,22 +16,13 @@ import (
|
|||||||
|
|
||||||
// startServer sets up the production server for Beszel
|
// startServer sets up the production server for Beszel
|
||||||
func (h *Hub) startServer(se *core.ServeEvent) error {
|
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")
|
indexFile, _ := fs.ReadFile(site.DistDirFS, "index.html")
|
||||||
html := strings.ReplaceAll(string(indexFile), "./", basePath)
|
html := modifyIndexHTML(h, indexFile)
|
||||||
html = strings.Replace(html, "{{V}}", beszel.Version, 1)
|
|
||||||
html = strings.Replace(html, "{{HUB_URL}}", h.appURL, 1)
|
|
||||||
// set up static asset serving
|
// set up static asset serving
|
||||||
staticPaths := [2]string{"/static/", "/assets/"}
|
staticPaths := [2]string{"/static/", "/assets/"}
|
||||||
serveStatic := apis.Static(site.DistDirFS, false)
|
serveStatic := apis.Static(site.DistDirFS, false)
|
||||||
// get CSP configuration
|
// get CSP configuration
|
||||||
csp, cspExists := GetEnv("CSP")
|
csp, cspExists := utils.GetEnv("CSP")
|
||||||
// add route
|
// add route
|
||||||
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
se.Router.GET("/{path...}", func(e *core.RequestEvent) error {
|
||||||
// serve static assets if path is in staticPaths
|
// serve static assets if path is in staticPaths
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/henrygd/beszel/internal/common"
|
"github.com/henrygd/beszel/internal/common"
|
||||||
"github.com/henrygd/beszel/internal/hub/transport"
|
"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/hub/ws"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
@@ -145,6 +146,7 @@ func (sys *System) update() error {
|
|||||||
// update smart interval if it's set on the agent side
|
// update smart interval if it's set on the agent side
|
||||||
if data.Details.SmartInterval > 0 {
|
if data.Details.SmartInterval > 0 {
|
||||||
sys.smartInterval = data.Details.SmartInterval
|
sys.smartInterval = data.Details.SmartInterval
|
||||||
|
sys.manager.hub.Logger().Info("SMART interval updated from agent details", "system", sys.Id, "interval", sys.smartInterval.String())
|
||||||
// make sure we reset expiration of lastFetch to remain as long as the new smart interval
|
// make sure we reset expiration of lastFetch to remain as long as the new smart interval
|
||||||
// to prevent premature expiration leading to new fetch if interval is different.
|
// to prevent premature expiration leading to new fetch if interval is different.
|
||||||
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
|
||||||
@@ -156,11 +158,10 @@ func (sys *System) update() error {
|
|||||||
if sys.smartInterval <= 0 {
|
if sys.smartInterval <= 0 {
|
||||||
sys.smartInterval = time.Hour
|
sys.smartInterval = time.Hour
|
||||||
}
|
}
|
||||||
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id)
|
if sys.shouldFetchSmart() && sys.smartFetching.CompareAndSwap(false, true) {
|
||||||
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
|
sys.manager.hub.Logger().Info("SMART fetch", "system", sys.Id, "interval", sys.smartInterval.String())
|
||||||
go func() {
|
go func() {
|
||||||
defer sys.smartFetching.Store(false)
|
defer sys.smartFetching.Store(false)
|
||||||
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute)
|
|
||||||
_ = sys.FetchAndSaveSmartDevices()
|
_ = sys.FetchAndSaveSmartDevices()
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -184,7 +185,7 @@ func (sys *System) handlePaused() {
|
|||||||
|
|
||||||
// createRecords updates the system record and adds system_stats and container_stats records
|
// createRecords updates the system record and adds system_stats and container_stats records
|
||||||
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
|
||||||
systemRecord, err := sys.getRecord()
|
systemRecord, err := sys.getRecord(sys.manager.hub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -343,8 +344,8 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
|
|||||||
|
|
||||||
// getRecord retrieves the system record from the database.
|
// getRecord retrieves the system record from the database.
|
||||||
// If the record is not found, it removes the system from the manager.
|
// If the record is not found, it removes the system from the manager.
|
||||||
func (sys *System) getRecord() (*core.Record, error) {
|
func (sys *System) getRecord(app core.App) (*core.Record, error) {
|
||||||
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
|
record, err := app.FindRecordById("systems", sys.Id)
|
||||||
if err != nil || record == nil {
|
if err != nil || record == nil {
|
||||||
_ = sys.manager.RemoveSystem(sys.Id)
|
_ = sys.manager.RemoveSystem(sys.Id)
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -352,6 +353,27 @@ func (sys *System) getRecord() (*core.Record, error) {
|
|||||||
return record, nil
|
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.
|
// 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
|
// 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.
|
// encountered during the process of updating the system status.
|
||||||
@@ -359,7 +381,7 @@ func (sys *System) setDown(originalError error) error {
|
|||||||
if sys.Status == down || sys.Status == paused {
|
if sys.Status == down || sys.Status == paused {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
record, err := sys.getRecord()
|
record, err := sys.getRecord(sys.manager.hub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -643,6 +665,7 @@ func (s *System) createSSHClient() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
|
||||||
|
s.manager.resetFailedSmartFetchState(s.Id)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ var errSystemExists = errors.New("system exists")
|
|||||||
// SystemManager manages a collection of monitored systems and their connections.
|
// SystemManager manages a collection of monitored systems and their connections.
|
||||||
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
|
||||||
type SystemManager struct {
|
type SystemManager struct {
|
||||||
hub hubLike // Hub interface for database and alert operations
|
hub hubLike // Hub interface for database and alert operations
|
||||||
systems *store.Store[string, *System] // Thread-safe store of active systems
|
systems *store.Store[string, *System] // Thread-safe store of active systems
|
||||||
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
|
||||||
smartFetchMap *expirymap.ExpiryMap[int64] // Stores last SMART fetch time per system ID
|
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
// hubLike defines the interface requirements for the hub dependency.
|
// hubLike defines the interface requirements for the hub dependency.
|
||||||
@@ -54,6 +54,7 @@ type hubLike interface {
|
|||||||
GetSSHKey(dataDir string) (ssh.Signer, error)
|
GetSSHKey(dataDir string) (ssh.Signer, error)
|
||||||
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
|
||||||
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
HandleStatusAlerts(status string, systemRecord *core.Record) error
|
||||||
|
CancelPendingStatusAlerts(systemID string)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
// NewSystemManager creates a new SystemManager instance with the provided hub.
|
||||||
@@ -62,7 +63,7 @@ func NewSystemManager(hub hubLike) *SystemManager {
|
|||||||
return &SystemManager{
|
return &SystemManager{
|
||||||
systems: store.New(map[string]*System{}),
|
systems: store.New(map[string]*System{}),
|
||||||
hub: hub,
|
hub: hub,
|
||||||
smartFetchMap: expirymap.New[int64](time.Hour),
|
smartFetchMap: expirymap.New[smartFetchState](time.Hour),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +190,7 @@ func (sm *SystemManager) onRecordAfterUpdateSuccess(e *core.RecordEvent) error {
|
|||||||
system.closeSSHConnection()
|
system.closeSSHConnection()
|
||||||
}
|
}
|
||||||
_ = deactivateAlerts(e.App, e.Record.Id)
|
_ = deactivateAlerts(e.App, e.Record.Id)
|
||||||
|
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
|
||||||
return e.Next()
|
return e.Next()
|
||||||
case pending:
|
case pending:
|
||||||
// Resume monitoring, preferring existing WebSocket connection
|
// Resume monitoring, preferring existing WebSocket connection
|
||||||
@@ -306,6 +308,7 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
sm.resetFailedSmartFetchState(systemId)
|
||||||
|
|
||||||
system := sm.NewSystem(systemId)
|
system := sm.NewSystem(systemId)
|
||||||
system.WsConn = wsConn
|
system.WsConn = wsConn
|
||||||
@@ -317,6 +320,15 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resetFailedSmartFetchState clears only failed SMART cooldown entries so a fresh
|
||||||
|
// agent reconnect retries SMART discovery immediately after configuration changes.
|
||||||
|
func (sm *SystemManager) resetFailedSmartFetchState(systemID string) {
|
||||||
|
state, ok := sm.smartFetchMap.GetOk(systemID)
|
||||||
|
if ok && !state.Successful {
|
||||||
|
sm.smartFetchMap.Remove(systemID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
// createSSHClientConfig initializes the SSH client configuration for connecting to an agent's server
|
||||||
func (sm *SystemManager) createSSHClientConfig() error {
|
func (sm *SystemManager) createSSHClientConfig() error {
|
||||||
privateKey, err := sm.hub.GetSSHKey("")
|
privateKey, err := sm.hub.GetSSHKey("")
|
||||||
|
|||||||
@@ -4,18 +4,61 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/smart"
|
"github.com/henrygd/beszel/internal/entities/smart"
|
||||||
"github.com/pocketbase/pocketbase/core"
|
"github.com/pocketbase/pocketbase/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type smartFetchState struct {
|
||||||
|
LastAttempt int64
|
||||||
|
Successful bool
|
||||||
|
}
|
||||||
|
|
||||||
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
// FetchAndSaveSmartDevices fetches SMART data from the agent and saves it to the database
|
||||||
func (sys *System) FetchAndSaveSmartDevices() error {
|
func (sys *System) FetchAndSaveSmartDevices() error {
|
||||||
smartData, err := sys.FetchSmartDataFromAgent()
|
smartData, err := sys.FetchSmartDataFromAgent()
|
||||||
if err != nil || len(smartData) == 0 {
|
if err != nil {
|
||||||
|
sys.recordSmartFetchResult(err, 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return sys.saveSmartDevices(smartData)
|
err = sys.saveSmartDevices(smartData)
|
||||||
|
sys.recordSmartFetchResult(err, len(smartData))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSmartFetchResult stores a cooldown entry for the SMART interval and marks
|
||||||
|
// whether the last fetch produced any devices, so failed setup can retry on reconnect.
|
||||||
|
func (sys *System) recordSmartFetchResult(err error, deviceCount int) {
|
||||||
|
if sys.manager == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
interval := sys.smartFetchInterval()
|
||||||
|
success := err == nil && deviceCount > 0
|
||||||
|
if sys.manager.hub != nil {
|
||||||
|
sys.manager.hub.Logger().Info("SMART fetch result", "system", sys.Id, "success", success, "devices", deviceCount, "interval", interval.String(), "err", err)
|
||||||
|
}
|
||||||
|
sys.manager.smartFetchMap.Set(sys.Id, smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: success}, interval+time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldFetchSmart returns true when there is no active SMART cooldown entry for this system.
|
||||||
|
func (sys *System) shouldFetchSmart() bool {
|
||||||
|
if sys.manager == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
state, ok := sys.manager.smartFetchMap.GetOk(sys.Id)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return !time.UnixMilli(state.LastAttempt).Add(sys.smartFetchInterval()).After(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
// smartFetchInterval returns the agent-provided SMART interval or the default when unset.
|
||||||
|
func (sys *System) smartFetchInterval() time.Duration {
|
||||||
|
if sys.smartInterval > 0 {
|
||||||
|
return sys.smartInterval
|
||||||
|
}
|
||||||
|
return time.Hour
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveSmartDevices saves SMART device data to the smart_devices collection
|
// saveSmartDevices saves SMART device data to the smart_devices collection
|
||||||
|
|||||||
94
internal/hub/systems/system_smart_test.go
Normal file
94
internal/hub/systems/system_smart_test.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//go:build testing
|
||||||
|
|
||||||
|
package systems
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/henrygd/beszel/internal/hub/expirymap"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRecordSmartFetchResult(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful fetch with devices
|
||||||
|
sys.recordSmartFetchResult(nil, 5)
|
||||||
|
state, ok := sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected smart fetch result to be stored")
|
||||||
|
assert.True(t, state.Successful, "expected successful fetch state to be recorded")
|
||||||
|
|
||||||
|
// Failed fetch
|
||||||
|
sys.recordSmartFetchResult(errors.New("failed"), 0)
|
||||||
|
state, ok = sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected failed smart fetch state to be stored")
|
||||||
|
assert.False(t, state.Successful, "expected failed smart fetch state to be marked unsuccessful")
|
||||||
|
|
||||||
|
// Successful fetch but no devices
|
||||||
|
sys.recordSmartFetchResult(nil, 0)
|
||||||
|
state, ok = sm.smartFetchMap.GetOk(sys.Id)
|
||||||
|
assert.True(t, ok, "expected fetch with zero devices to be stored")
|
||||||
|
assert.False(t, state.Successful, "expected fetch with zero devices to be marked unsuccessful")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldFetchSmart(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected initial smart fetch to be allowed")
|
||||||
|
|
||||||
|
sys.recordSmartFetchResult(errors.New("failed"), 0)
|
||||||
|
assert.False(t, sys.shouldFetchSmart(), "expected smart fetch to be blocked while interval entry exists")
|
||||||
|
|
||||||
|
sm.smartFetchMap.Remove(sys.Id)
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected smart fetch to be allowed after interval entry is cleared")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldFetchSmart_IgnoresExtendedTTLWhenFetchIsDue(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sys := &System{
|
||||||
|
Id: "system-1",
|
||||||
|
manager: sm,
|
||||||
|
smartInterval: time.Hour,
|
||||||
|
}
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set(sys.Id, smartFetchState{
|
||||||
|
LastAttempt: time.Now().Add(-2 * time.Hour).UnixMilli(),
|
||||||
|
Successful: true,
|
||||||
|
}, 10*time.Minute)
|
||||||
|
sm.smartFetchMap.UpdateExpiration(sys.Id, 3*time.Hour)
|
||||||
|
|
||||||
|
assert.True(t, sys.shouldFetchSmart(), "expected fetch time to take precedence over updated TTL")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResetFailedSmartFetchState(t *testing.T) {
|
||||||
|
sm := &SystemManager{smartFetchMap: expirymap.New[smartFetchState](time.Hour)}
|
||||||
|
t.Cleanup(sm.smartFetchMap.StopCleaner)
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set("system-1", smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: false}, time.Hour)
|
||||||
|
sm.resetFailedSmartFetchState("system-1")
|
||||||
|
_, ok := sm.smartFetchMap.GetOk("system-1")
|
||||||
|
assert.False(t, ok, "expected failed smart fetch state to be cleared on reconnect")
|
||||||
|
|
||||||
|
sm.smartFetchMap.Set("system-1", smartFetchState{LastAttempt: time.Now().UnixMilli(), Successful: true}, time.Hour)
|
||||||
|
sm.resetFailedSmartFetchState("system-1")
|
||||||
|
_, ok = sm.smartFetchMap.GetOk("system-1")
|
||||||
|
assert.True(t, ok, "expected successful smart fetch state to be preserved")
|
||||||
|
}
|
||||||
@@ -421,3 +421,60 @@ func testOld(t *testing.T, hub *tests.TestHub) {
|
|||||||
assert.NoError(t, err)
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
12
internal/hub/utils/utils.go
Normal file
12
internal/hub/utils/utils.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Package utils provides utility functions for the hub.
|
||||||
|
package utils
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
@@ -3,10 +3,8 @@ package records
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log"
|
"log"
|
||||||
"math"
|
"math"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/entities/container"
|
"github.com/henrygd/beszel/internal/entities/container"
|
||||||
@@ -39,16 +37,6 @@ type StatsRecord struct {
|
|||||||
Stats []byte `db:"stats"`
|
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
|
// Create longer records by averaging shorter records
|
||||||
func (rm *RecordManager) CreateLongerRecords() {
|
func (rm *RecordManager) CreateLongerRecords() {
|
||||||
// start := time.Now()
|
// start := time.Now()
|
||||||
@@ -163,41 +151,47 @@ func (rm *RecordManager) CreateLongerRecords() {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
statsRecord.Stats = statsRecord.Stats[:0]
|
|
||||||
|
|
||||||
// log.Println("finished creating longer records", "time (ms)", time.Since(start).Milliseconds())
|
// 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
|
// Calculate the average stats of a list of system_stats records without reflect
|
||||||
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
|
func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *system.Stats {
|
||||||
// Clear/reset global structs for reuse
|
stats := make([]system.Stats, 0, len(records))
|
||||||
sumStats = system.Stats{}
|
var row StatsRecord
|
||||||
tempStats = system.Stats{}
|
params := make(dbx.Params, 1)
|
||||||
sum := &sumStats
|
for _, rec := range records {
|
||||||
stats := &tempStats
|
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
|
// necessary because uint8 is not big enough for the sum
|
||||||
batterySum := 0
|
batterySum := 0
|
||||||
// accumulate per-core usage across records
|
// accumulate per-core usage across records
|
||||||
var cpuCoresSums []uint64
|
var cpuCoresSums []uint64
|
||||||
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
// accumulate cpu breakdown [user, system, iowait, steal, idle]
|
||||||
var cpuBreakdownSums []float64
|
var cpuBreakdownSums []float64
|
||||||
|
|
||||||
count := float64(len(records))
|
|
||||||
tempCount := float64(0)
|
tempCount := float64(0)
|
||||||
|
|
||||||
// Accumulate totals
|
// Accumulate totals
|
||||||
for _, record := range records {
|
for i := range records {
|
||||||
id := record.Id
|
stats := &records[i]
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
sum.Cpu += stats.Cpu
|
sum.Cpu += stats.Cpu
|
||||||
// accumulate cpu time breakdowns if present
|
// accumulate cpu time breakdowns if present
|
||||||
@@ -205,8 +199,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
if len(cpuBreakdownSums) < len(stats.CpuBreakdown) {
|
||||||
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
cpuBreakdownSums = append(cpuBreakdownSums, make([]float64, len(stats.CpuBreakdown)-len(cpuBreakdownSums))...)
|
||||||
}
|
}
|
||||||
for i, v := range stats.CpuBreakdown {
|
for j, v := range stats.CpuBreakdown {
|
||||||
cpuBreakdownSums[i] += v
|
cpuBreakdownSums[j] += v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sum.Mem += stats.Mem
|
sum.Mem += stats.Mem
|
||||||
@@ -230,6 +224,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.Bandwidth[1] += stats.Bandwidth[1]
|
sum.Bandwidth[1] += stats.Bandwidth[1]
|
||||||
sum.DiskIO[0] += stats.DiskIO[0]
|
sum.DiskIO[0] += stats.DiskIO[0]
|
||||||
sum.DiskIO[1] += stats.DiskIO[1]
|
sum.DiskIO[1] += stats.DiskIO[1]
|
||||||
|
for i := range stats.DiskIoStats {
|
||||||
|
sum.DiskIoStats[i] += stats.DiskIoStats[i]
|
||||||
|
}
|
||||||
batterySum += int(stats.Battery[0])
|
batterySum += int(stats.Battery[0])
|
||||||
sum.Battery[1] = stats.Battery[1]
|
sum.Battery[1] = stats.Battery[1]
|
||||||
|
|
||||||
@@ -239,8 +236,8 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
// extend slices to accommodate core count
|
// extend slices to accommodate core count
|
||||||
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
cpuCoresSums = append(cpuCoresSums, make([]uint64, len(stats.CpuCoresUsage)-len(cpuCoresSums))...)
|
||||||
}
|
}
|
||||||
for i, v := range stats.CpuCoresUsage {
|
for j, v := range stats.CpuCoresUsage {
|
||||||
cpuCoresSums[i] += uint64(v)
|
cpuCoresSums[j] += uint64(v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Set peak values
|
// Set peak values
|
||||||
@@ -254,6 +251,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
|
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[0] = max(sum.MaxDiskIO[0], stats.MaxDiskIO[0], stats.DiskIO[0])
|
||||||
sum.MaxDiskIO[1] = max(sum.MaxDiskIO[1], stats.MaxDiskIO[1], stats.DiskIO[1])
|
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
|
// Accumulate network interfaces
|
||||||
if sum.NetworkInterfaces == nil {
|
if sum.NetworkInterfaces == nil {
|
||||||
@@ -299,6 +299,10 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
fs.DiskWriteBytes += value.DiskWriteBytes
|
fs.DiskWriteBytes += value.DiskWriteBytes
|
||||||
fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes)
|
fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes)
|
||||||
fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes)
|
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 +337,107 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute averages in place
|
// Compute averages
|
||||||
if count > 0 {
|
sum.Cpu = twoDecimals(sum.Cpu / count)
|
||||||
sum.Cpu = twoDecimals(sum.Cpu / count)
|
sum.Mem = twoDecimals(sum.Mem / count)
|
||||||
sum.Mem = twoDecimals(sum.Mem / count)
|
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
||||||
sum.MemUsed = twoDecimals(sum.MemUsed / count)
|
sum.MemPct = twoDecimals(sum.MemPct / count)
|
||||||
sum.MemPct = twoDecimals(sum.MemPct / count)
|
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
||||||
sum.MemBuffCache = twoDecimals(sum.MemBuffCache / count)
|
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
||||||
sum.MemZfsArc = twoDecimals(sum.MemZfsArc / count)
|
sum.Swap = twoDecimals(sum.Swap / count)
|
||||||
sum.Swap = twoDecimals(sum.Swap / count)
|
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
||||||
sum.SwapUsed = twoDecimals(sum.SwapUsed / count)
|
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
||||||
sum.DiskTotal = twoDecimals(sum.DiskTotal / count)
|
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
||||||
sum.DiskUsed = twoDecimals(sum.DiskUsed / count)
|
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
||||||
sum.DiskPct = twoDecimals(sum.DiskPct / count)
|
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
||||||
sum.DiskReadPs = twoDecimals(sum.DiskReadPs / count)
|
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
||||||
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
|
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
||||||
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
|
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
||||||
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
|
for i := range sum.DiskIoStats {
|
||||||
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
|
||||||
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
}
|
||||||
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
|
||||||
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
|
||||||
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
|
||||||
sum.Bandwidth[0] = sum.Bandwidth[0] / uint64(count)
|
sum.LoadAvg[1] = twoDecimals(sum.LoadAvg[1] / count)
|
||||||
sum.Bandwidth[1] = sum.Bandwidth[1] / uint64(count)
|
sum.LoadAvg[2] = twoDecimals(sum.LoadAvg[2] / count)
|
||||||
sum.Battery[0] = uint8(batterySum / int(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
|
// Average network interfaces
|
||||||
if sum.NetworkInterfaces != nil {
|
if sum.NetworkInterfaces != nil {
|
||||||
for key := range sum.NetworkInterfaces {
|
for key := range sum.NetworkInterfaces {
|
||||||
sum.NetworkInterfaces[key] = [4]uint64{
|
sum.NetworkInterfaces[key] = [4]uint64{
|
||||||
sum.NetworkInterfaces[key][0] / uint64(count),
|
sum.NetworkInterfaces[key][0] / uint64(count),
|
||||||
sum.NetworkInterfaces[key][1] / uint64(count),
|
sum.NetworkInterfaces[key][1] / uint64(count),
|
||||||
sum.NetworkInterfaces[key][2],
|
sum.NetworkInterfaces[key][2],
|
||||||
sum.NetworkInterfaces[key][3],
|
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
|
// Average per-core usage
|
||||||
if sum.Temperatures != nil && tempCount > 0 {
|
if len(cpuCoresSums) > 0 {
|
||||||
for key := range sum.Temperatures {
|
avg := make(system.Uint8Slice, len(cpuCoresSums))
|
||||||
sum.Temperatures[key] = twoDecimals(sum.Temperatures[key] / tempCount)
|
for i := range cpuCoresSums {
|
||||||
}
|
v := math.Round(float64(cpuCoresSums[i]) / count)
|
||||||
|
avg[i] = uint8(v)
|
||||||
}
|
}
|
||||||
|
sum.CpuCoresUsage = avg
|
||||||
|
}
|
||||||
|
|
||||||
// Average extra filesystem stats
|
// Average CPU breakdown
|
||||||
if sum.ExtraFs != nil {
|
if len(cpuBreakdownSums) > 0 {
|
||||||
for key := range sum.ExtraFs {
|
avg := make([]float64, len(cpuBreakdownSums))
|
||||||
fs := sum.ExtraFs[key]
|
for i := range cpuBreakdownSums {
|
||||||
fs.DiskTotal = twoDecimals(fs.DiskTotal / count)
|
avg[i] = twoDecimals(cpuBreakdownSums[i] / 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
|
|
||||||
}
|
}
|
||||||
|
sum.CpuBreakdown = avg
|
||||||
}
|
}
|
||||||
|
|
||||||
return sum
|
return sum
|
||||||
@@ -437,29 +445,33 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
|
|||||||
|
|
||||||
// Calculate the average stats of a list of container_stats records
|
// Calculate the average stats of a list of container_stats records
|
||||||
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds) []container.Stats {
|
||||||
// Clear global map for reuse
|
allStats := make([][]container.Stats, 0, len(records))
|
||||||
for k := range containerSums {
|
var row StatsRecord
|
||||||
delete(containerSums, k)
|
params := make(dbx.Params, 1)
|
||||||
}
|
for _, rec := range records {
|
||||||
sums := containerSums
|
row.Stats = row.Stats[:0]
|
||||||
count := float64(len(records))
|
params["id"] = rec.Id
|
||||||
|
db.NewQuery("SELECT stats FROM container_stats WHERE id = {:id}").Bind(params).One(&row)
|
||||||
for i := range records {
|
var cs []container.Stats
|
||||||
id := records[i].Id
|
if err := json.Unmarshal(row.Stats, &cs); err != nil {
|
||||||
// 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 {
|
|
||||||
return []container.Stats{}
|
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 {
|
for i := range containerStats {
|
||||||
stat := containerStats[i]
|
stat := &containerStats[i]
|
||||||
if _, ok := sums[stat.Name]; !ok {
|
if _, ok := sums[stat.Name]; !ok {
|
||||||
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
sums[stat.Name] = &container.Stats{Name: stat.Name}
|
||||||
}
|
}
|
||||||
@@ -488,133 +500,6 @@ func (rm *RecordManager) AverageContainerStats(db dbx.Builder, records RecordIds
|
|||||||
return result
|
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
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 := [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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Round float to two decimals */
|
/* Round float to two decimals */
|
||||||
func twoDecimals(value float64) float64 {
|
func twoDecimals(value float64) float64 {
|
||||||
return math.Round(value*100) / 100
|
return math.Round(value*100) / 100
|
||||||
|
|||||||
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 := [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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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
|
package records_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/henrygd/beszel/internal/records"
|
"github.com/henrygd/beszel/internal/records"
|
||||||
"github.com/henrygd/beszel/internal/tests"
|
"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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// TestRecordManagerCreation tests RecordManager creation
|
||||||
func TestRecordManagerCreation(t *testing.T) {
|
func TestRecordManagerCreation(t *testing.T) {
|
||||||
hub, err := tests.NewTestHub(t.TempDir())
|
hub, err := tests.NewTestHub(t.TempDir())
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"valibot": "^0.42.1",
|
"valibot": "^1.3.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.4",
|
"@biomejs/biome": "2.2.4",
|
||||||
@@ -927,7 +927,7 @@
|
|||||||
|
|
||||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
"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=="],
|
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
|
||||||
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
<link rel="icon" type="image/svg+xml" href="./static/icon.svg" />
|
||||||
|
<link rel="apple-touch-icon" href="./static/icon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0,maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>Beszel</title>
|
<title>Beszel</title>
|
||||||
@@ -21,11 +22,7 @@
|
|||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
globalThis.BESZEL = {
|
globalThis.BESZEL = "{info}"
|
||||||
BASE_PATH: "%BASE_URL%",
|
|
||||||
HUB_VERSION: "{{V}}",
|
|
||||||
HUB_URL: "{{HUB_URL}}"
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
407
internal/site/package-lock.json
generated
407
internal/site/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.18.3",
|
"version": "0.18.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"version": "0.18.3",
|
"version": "0.18.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@henrygd/queue": "^1.0.7",
|
"@henrygd/queue": "^1.0.7",
|
||||||
"@henrygd/semaphore": "^0.0.2",
|
"@henrygd/semaphore": "^0.0.2",
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^1.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.4",
|
"@biomejs/biome": "2.2.4",
|
||||||
@@ -986,29 +986,6 @@
|
|||||||
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
|
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1243,9 +1220,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@lingui/cli/node_modules/picomatch": {
|
"node_modules/@lingui/cli/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -2408,9 +2385,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
|
||||||
"integrity": "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==",
|
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2422,9 +2399,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-android-arm64": {
|
"node_modules/@rollup/rollup-android-arm64": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==",
|
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2436,9 +2413,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==",
|
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2450,9 +2427,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-darwin-x64": {
|
"node_modules/@rollup/rollup-darwin-x64": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==",
|
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2464,9 +2441,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
|
||||||
"integrity": "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==",
|
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2478,9 +2455,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
|
||||||
"integrity": "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==",
|
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2492,9 +2469,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==",
|
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2506,9 +2483,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
|
||||||
"integrity": "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==",
|
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -2520,9 +2497,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==",
|
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2534,9 +2511,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==",
|
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2547,10 +2524,24 @@
|
|||||||
"linux"
|
"linux"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==",
|
"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": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
@@ -2562,9 +2553,23 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==",
|
"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": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2576,9 +2581,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==",
|
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2590,9 +2595,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==",
|
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
@@ -2604,9 +2609,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==",
|
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -2618,9 +2623,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
|
||||||
"integrity": "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==",
|
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2632,9 +2637,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
|
||||||
"integrity": "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==",
|
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2645,10 +2650,38 @@
|
|||||||
"linux"
|
"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": {
|
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==",
|
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2660,9 +2693,9 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==",
|
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
@@ -2673,10 +2706,24 @@
|
|||||||
"win32"
|
"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": {
|
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
|
||||||
"integrity": "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==",
|
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -3235,6 +3282,66 @@
|
|||||||
"node": ">=14.0.0"
|
"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": {
|
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
|
"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": {
|
"node_modules/anymatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -3620,6 +3727,16 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -3666,6 +3783,19 @@
|
|||||||
"readable-stream": "^3.4.0"
|
"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": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
@@ -5072,9 +5202,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/lodash.sortby": {
|
"node_modules/lodash.sortby": {
|
||||||
@@ -5267,9 +5397,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/micromatch/node_modules/picomatch": {
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5290,16 +5420,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "10.1.1",
|
"version": "10.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
|
||||||
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
|
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/brace-expansion": "^5.0.0"
|
"brace-expansion": "^5.0.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
@@ -5575,9 +5705,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5956,9 +6086,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.48.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
"integrity": "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==",
|
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -5972,26 +6102,31 @@
|
|||||||
"npm": ">=8.0.0"
|
"npm": ">=8.0.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-android-arm-eabi": "4.48.1",
|
"@rollup/rollup-android-arm-eabi": "4.60.1",
|
||||||
"@rollup/rollup-android-arm64": "4.48.1",
|
"@rollup/rollup-android-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-arm64": "4.48.1",
|
"@rollup/rollup-darwin-arm64": "4.60.1",
|
||||||
"@rollup/rollup-darwin-x64": "4.48.1",
|
"@rollup/rollup-darwin-x64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-arm64": "4.48.1",
|
"@rollup/rollup-freebsd-arm64": "4.60.1",
|
||||||
"@rollup/rollup-freebsd-x64": "4.48.1",
|
"@rollup/rollup-freebsd-x64": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-gnueabihf": "4.48.1",
|
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm-musleabihf": "4.48.1",
|
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-gnu": "4.48.1",
|
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.48.1",
|
"@rollup/rollup-linux-arm64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-loongarch64-gnu": "4.48.1",
|
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-ppc64-gnu": "4.48.1",
|
"@rollup/rollup-linux-loong64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-gnu": "4.48.1",
|
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-riscv64-musl": "4.48.1",
|
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-s390x-gnu": "4.48.1",
|
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.48.1",
|
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.48.1",
|
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
|
||||||
"@rollup/rollup-win32-arm64-msvc": "4.48.1",
|
"@rollup/rollup-linux-x64-gnu": "4.60.1",
|
||||||
"@rollup/rollup-win32-ia32-msvc": "4.48.1",
|
"@rollup/rollup-linux-x64-musl": "4.60.1",
|
||||||
"@rollup/rollup-win32-x64-msvc": "4.48.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"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -6290,9 +6425,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tar": {
|
"node_modules/tar": {
|
||||||
"version": "7.5.7",
|
"version": "7.5.13",
|
||||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
|
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
|
||||||
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
|
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -6559,9 +6694,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/valibot": {
|
"node_modules/valibot": {
|
||||||
"version": "0.42.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/valibot/-/valibot-0.42.1.tgz",
|
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz",
|
||||||
"integrity": "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==",
|
"integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": ">=5"
|
"typescript": ">=5"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "beszel",
|
"name": "beszel",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.18.5",
|
"version": "0.18.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host",
|
"dev": "vite --host",
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
"recharts": "^2.15.4",
|
"recharts": "^2.15.4",
|
||||||
"shiki": "^3.13.0",
|
"shiki": "^3.13.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"valibot": "^0.42.1"
|
"valibot": "^1.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.4",
|
"@biomejs/biome": "2.2.4",
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
|
|||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
|
||||||
<BellIcon
|
<BellIcon
|
||||||
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
|
className={cn("size-[1.2em] pointer-events-none", {
|
||||||
"fill-primary": hasSystemAlert,
|
"fill-primary": hasSystemAlert,
|
||||||
})}
|
})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { t } from "@lingui/core/macro"
|
|||||||
import { Plural, Trans } from "@lingui/react/macro"
|
import { Plural, Trans } from "@lingui/react/macro"
|
||||||
import { useStore } from "@nanostores/react"
|
import { useStore } from "@nanostores/react"
|
||||||
import { getPagePath } from "@nanostores/router"
|
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 { lazy, memo, Suspense, useMemo, useState } from "react"
|
||||||
import { $router, Link } from "@/components/router"
|
import { $router, Link } from "@/components/router"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
|
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 { Input } from "@/components/ui/input"
|
||||||
import { Switch } from "@/components/ui/switch"
|
import { Switch } from "@/components/ui/switch"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
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 }) {
|
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
|
||||||
const alerts = useStore($alerts)
|
const alerts = useStore($alerts)
|
||||||
|
const systems = useStore($systems)
|
||||||
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
|
||||||
const [currentTab, setCurrentTab] = useState("system")
|
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()
|
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
|
// 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
|
// current alerts, it will only be updated when first checked, then won't be updated because
|
||||||
// after that it exists.
|
// after that it exists.
|
||||||
@@ -93,18 +141,37 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
|
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
|
||||||
<TabsList className="mb-1 -mt-0.5">
|
<div className="flex items-center justify-between mb-1 -mt-0.5">
|
||||||
<TabsTrigger value="system">
|
<TabsList>
|
||||||
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
<TabsTrigger value="system">
|
||||||
<span className="truncate max-w-60">{system.name}</span>
|
<ServerIcon className="me-2 h-3.5 w-3.5" />
|
||||||
</TabsTrigger>
|
<span className="truncate max-w-60">{system.name}</span>
|
||||||
<TabsTrigger value="global">
|
</TabsTrigger>
|
||||||
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
<TabsTrigger value="global">
|
||||||
<Trans>All Systems</Trans>
|
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
|
||||||
</TabsTrigger>
|
<Trans>All Systems</Trans>
|
||||||
</TabsList>
|
</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">
|
<TabsContent value="system">
|
||||||
<div className="grid gap-3">
|
<div key={copyKey} className="grid gap-3">
|
||||||
{alertKeys.map((name) => (
|
{alertKeys.map((name) => (
|
||||||
<AlertContent
|
<AlertContent
|
||||||
key={name}
|
key={name}
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ export default function AreaChartDefault({
|
|||||||
hideYAxis = false,
|
hideYAxis = false,
|
||||||
filter,
|
filter,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
}: // logRender = false,
|
chartProps,
|
||||||
{
|
}: {
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
customData?: any[]
|
customData?: any[]
|
||||||
@@ -62,13 +62,13 @@ export default function AreaChartDefault({
|
|||||||
hideYAxis?: boolean
|
hideYAxis?: boolean
|
||||||
filter?: string
|
filter?: string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
// logRender?: boolean
|
chartProps?: Omit<React.ComponentProps<typeof AreaChart>, "data" | "margin">
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats
|
||||||
// Only update the rendered data while the chart is visible
|
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,7 +78,10 @@ export default function AreaChartDefault({
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
setDisplayData(sourceData)
|
setDisplayData(sourceData)
|
||||||
}
|
}
|
||||||
}, [displayData, isIntersecting, sourceData])
|
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||||
|
setDisplayMaxToggled(maxToggled)
|
||||||
|
}
|
||||||
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// Use a stable key derived from data point identities and visual properties
|
||||||
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
|
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
|
||||||
@@ -106,14 +109,14 @@ export default function AreaChartDefault({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [areasKey, maxToggled])
|
}, [areasKey, displayMaxToggled])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -128,6 +131,7 @@ export default function AreaChartDefault({
|
|||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={displayData}
|
data={displayData}
|
||||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||||
|
{...chartProps}
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
{!hideYAxis && (
|
{!hideYAxis && (
|
||||||
@@ -163,5 +167,5 @@ export default function AreaChartDefault({
|
|||||||
</AreaChart>
|
</AreaChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, showTotal, filter])
|
}, [displayData, yAxisWidth, filter, Areas])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export default function LineChartDefault({
|
|||||||
hideYAxis = false,
|
hideYAxis = false,
|
||||||
filter,
|
filter,
|
||||||
truncate = false,
|
truncate = false,
|
||||||
}: // logRender = false,
|
chartProps,
|
||||||
{
|
}: {
|
||||||
chartData: ChartData
|
chartData: ChartData
|
||||||
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
|
||||||
customData?: any[]
|
customData?: any[]
|
||||||
@@ -61,13 +61,13 @@ export default function LineChartDefault({
|
|||||||
hideYAxis?: boolean
|
hideYAxis?: boolean
|
||||||
filter?: string
|
filter?: string
|
||||||
truncate?: boolean
|
truncate?: boolean
|
||||||
// logRender?: boolean
|
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
|
||||||
}) {
|
}) {
|
||||||
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
|
||||||
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
|
||||||
const sourceData = customData ?? chartData.systemStats
|
const sourceData = customData ?? chartData.systemStats
|
||||||
// Only update the rendered data while the chart is visible
|
|
||||||
const [displayData, setDisplayData] = useState(sourceData)
|
const [displayData, setDisplayData] = useState(sourceData)
|
||||||
|
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
|
||||||
|
|
||||||
// Reduce chart redraws by only updating while visible or when chart time changes
|
// Reduce chart redraws by only updating while visible or when chart time changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -77,7 +77,10 @@ export default function LineChartDefault({
|
|||||||
if (shouldUpdate) {
|
if (shouldUpdate) {
|
||||||
setDisplayData(sourceData)
|
setDisplayData(sourceData)
|
||||||
}
|
}
|
||||||
}, [displayData, isIntersecting, sourceData])
|
if (isIntersecting && maxToggled !== displayMaxToggled) {
|
||||||
|
setDisplayMaxToggled(maxToggled)
|
||||||
|
}
|
||||||
|
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
|
||||||
|
|
||||||
// Use a stable key derived from data point identities and visual properties
|
// Use a stable key derived from data point identities and visual properties
|
||||||
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
|
||||||
@@ -105,14 +108,14 @@ export default function LineChartDefault({
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}, [linesKey, maxToggled])
|
}, [linesKey, displayMaxToggled])
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
if (displayData.length === 0) {
|
if (displayData.length === 0) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// if (logRender) {
|
// if (logRender) {
|
||||||
// console.log("Rendered at", new Date(), "for", dataPoints?.at(0)?.label)
|
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
|
||||||
// }
|
// }
|
||||||
return (
|
return (
|
||||||
<ChartContainer
|
<ChartContainer
|
||||||
@@ -127,6 +130,7 @@ export default function LineChartDefault({
|
|||||||
accessibilityLayer
|
accessibilityLayer
|
||||||
data={displayData}
|
data={displayData}
|
||||||
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
|
||||||
|
{...chartProps}
|
||||||
>
|
>
|
||||||
<CartesianGrid vertical={false} />
|
<CartesianGrid vertical={false} />
|
||||||
{!hideYAxis && (
|
{!hideYAxis && (
|
||||||
@@ -162,5 +166,5 @@ export default function LineChartDefault({
|
|||||||
</LineChart>
|
</LineChart>
|
||||||
</ChartContainer>
|
</ChartContainer>
|
||||||
)
|
)
|
||||||
}, [displayData, yAxisWidth, showTotal, filter, chartData.chartTime])
|
}, [displayData, yAxisWidth, filter, Lines])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ import { Label } from "@/components/ui/label"
|
|||||||
import { pb } from "@/lib/api"
|
import { pb } from "@/lib/api"
|
||||||
import { $authenticated } from "@/lib/stores"
|
import { $authenticated } from "@/lib/stores"
|
||||||
import { cn } from "@/lib/utils"
|
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 { toast } from "../ui/use-toast"
|
||||||
import { OtpInputForm } from "./otp-forms"
|
import { OtpInputForm } from "./otp-forms"
|
||||||
|
|
||||||
const honeypot = v.literal("")
|
const honeypot = v.literal("")
|
||||||
const emailSchema = v.pipe(v.string(), v.email(t`Invalid email address.`))
|
const emailSchema = v.pipe(v.string(), v.rfcEmail(t`Invalid email address.`))
|
||||||
const passwordSchema = v.pipe(
|
const passwordSchema = v.pipe(
|
||||||
v.string(),
|
v.string(),
|
||||||
v.minLength(8, t`Password must be at least 8 characters.`),
|
v.minLength(8, t`Password must be at least 8 characters.`),
|
||||||
@@ -37,8 +37,7 @@ const RegisterSchema = v.looseObject({
|
|||||||
passwordConfirm: passwordSchema,
|
passwordConfirm: passwordSchema,
|
||||||
})
|
})
|
||||||
|
|
||||||
export const showLoginFaliedToast = (description?: string) => {
|
export const showLoginFaliedToast = (description = t`Please check your credentials and try again`) => {
|
||||||
description ||= t`Please check your credentials and try again`
|
|
||||||
toast({
|
toast({
|
||||||
title: t`Login attempt failed`,
|
title: t`Login attempt failed`,
|
||||||
description,
|
description,
|
||||||
@@ -130,10 +129,6 @@ export function UserAuthForm({
|
|||||||
[isFirstRun]
|
[isFirstRun]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!authMethods) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const authProviders = authMethods.oauth2.providers ?? []
|
const authProviders = authMethods.oauth2.providers ?? []
|
||||||
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
const oauthEnabled = authMethods.oauth2.enabled && authProviders.length > 0
|
||||||
const passwordEnabled = authMethods.password.enabled
|
const passwordEnabled = authMethods.password.enabled
|
||||||
@@ -142,6 +137,12 @@ export function UserAuthForm({
|
|||||||
|
|
||||||
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
function loginWithOauth(provider: AuthProviderInfo, forcePopup = false) {
|
||||||
setIsOauthLoading(true)
|
setIsOauthLoading(true)
|
||||||
|
|
||||||
|
if (globalThis.BESZEL.OAUTH_DISABLE_POPUP) {
|
||||||
|
redirectToOauthProvider(provider)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const oAuthOpts: OAuth2AuthConfig = {
|
const oAuthOpts: OAuth2AuthConfig = {
|
||||||
provider: provider.name,
|
provider: provider.name,
|
||||||
}
|
}
|
||||||
@@ -150,10 +151,7 @@ export function UserAuthForm({
|
|||||||
const authWindow = window.open()
|
const authWindow = window.open()
|
||||||
if (!authWindow) {
|
if (!authWindow) {
|
||||||
setIsOauthLoading(false)
|
setIsOauthLoading(false)
|
||||||
toast({
|
showLoginFaliedToast(t`Please enable pop-ups for this site`)
|
||||||
title: t`Error`,
|
|
||||||
description: t`Please enable pop-ups for this site`,
|
|
||||||
})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
oAuthOpts.urlCallback = (url) => {
|
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(() => {
|
useEffect(() => {
|
||||||
// auto login if password disabled and only one auth provider
|
// handle redirect-based OAuth callback if we have a code
|
||||||
if (!passwordEnabled && authProviders.length === 1 && !sessionStorage.getItem("lo")) {
|
const params = new URLSearchParams(window.location.search)
|
||||||
// Add a small timeout to ensure browser is ready to handle popups
|
const code = params.get("code")
|
||||||
setTimeout(() => {
|
if (code) {
|
||||||
loginWithOauth(authProviders[0], true)
|
const state = params.get("state")
|
||||||
}, 300)
|
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) {
|
if (otpId && mfaId) {
|
||||||
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
return <OtpInputForm otpId={otpId} mfaId={mfaId} />
|
||||||
}
|
}
|
||||||
@@ -248,7 +287,7 @@ export function UserAuthForm({
|
|||||||
)}
|
)}
|
||||||
<div className="sr-only">
|
<div className="sr-only">
|
||||||
{/* honeypot */}
|
{/* honeypot */}
|
||||||
<label htmlFor="website"></label>
|
<label htmlFor="website">Website</label>
|
||||||
<input
|
<input
|
||||||
id="website"
|
id="website"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -1,28 +1,39 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
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 { useTheme } from "@/components/theme-provider"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
|
||||||
import { Trans } from "@lingui/react/macro"
|
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() {
|
export function ModeToggle() {
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
const currentIndex = themes.indexOf(theme)
|
||||||
|
const Icon = icons[currentIndex]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
variant={"ghost"}
|
variant={"ghost"}
|
||||||
size="icon"
|
size="icon"
|
||||||
aria-label={t`Toggle theme`}
|
aria-label={t`Switch theme`}
|
||||||
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
|
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" />
|
<Icon
|
||||||
<MoonStarIcon className="absolute h-[1.2rem] w-[1.2rem] transition-all opacity-0 -rotate-90 dark:opacity-100 dark:rotate-0" />
|
className={cn(
|
||||||
|
"animate-in fade-in spin-in-[-30deg] duration-200",
|
||||||
|
currentIndex === 2 ? "size-[1.35rem]" : "size-[1.2rem]"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<Trans>Toggle theme</Trans>
|
<Trans>Switch theme</Trans>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function Navbar() {
|
|||||||
className="p-2 ps-0 me-3 group"
|
className="p-2 ps-0 me-3 group"
|
||||||
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
|
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>
|
</Link>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -125,15 +125,17 @@ export default function Navbar() {
|
|||||||
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
|
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
)}
|
)}
|
||||||
<DropdownMenuItem
|
{!isReadOnlyUser() && (
|
||||||
className="flex items-center"
|
<DropdownMenuItem
|
||||||
onSelect={() => {
|
className="flex items-center"
|
||||||
setAddSystemDialogOpen(true)
|
onSelect={() => {
|
||||||
}}
|
setAddSystemDialogOpen(true)
|
||||||
>
|
}}
|
||||||
<PlusIcon className="h-4 w-4 me-2.5" />
|
>
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<PlusIcon className="h-4 w-4 me-2.5" />
|
||||||
</DropdownMenuItem>
|
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
@@ -217,10 +219,12 @@ export default function Navbar() {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
{!isReadOnlyUser() && (
|
||||||
<PlusIcon className="h-4 w-4 -ms-1" />
|
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
|
||||||
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
<PlusIcon className="h-4 w-4 -ms-1" />
|
||||||
</Button>
|
<Trans>Add {{ foo: systemTranslation }}</Trans>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { isAdmin, pb } from "@/lib/api"
|
|||||||
import type { UserSettings } from "@/types"
|
import type { UserSettings } from "@/types"
|
||||||
import { saveSettings } from "./layout"
|
import { saveSettings } from "./layout"
|
||||||
import { QuietHours } from "./quiet-hours"
|
import { QuietHours } from "./quiet-hours"
|
||||||
|
import type { ClientResponseError } from "pocketbase"
|
||||||
|
|
||||||
interface ShoutrrrUrlCardProps {
|
interface ShoutrrrUrlCardProps {
|
||||||
url: string
|
url: string
|
||||||
@@ -23,7 +24,7 @@ interface ShoutrrrUrlCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const NotificationSchema = v.object({
|
const NotificationSchema = v.object({
|
||||||
emails: v.array(v.pipe(v.string(), v.email())),
|
emails: v.array(v.pipe(v.string(), v.rfcEmail())),
|
||||||
webhooks: v.array(v.pipe(v.string(), v.url())),
|
webhooks: v.array(v.pipe(v.string(), v.url())),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -59,10 +60,10 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
try {
|
try {
|
||||||
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
const parsedData = v.parse(NotificationSchema, { emails, webhooks })
|
||||||
await saveSettings(parsedData)
|
await saveSettings(parsedData)
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
toast({
|
toast({
|
||||||
title: t`Failed to save settings`,
|
title: t`Failed to save settings`,
|
||||||
description: e.message,
|
description: (e as Error).message,
|
||||||
variant: "destructive",
|
variant: "destructive",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -136,12 +137,7 @@ const SettingsNotificationsPage = ({ userSettings }: { userSettings: UserSetting
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button type="button" variant="outline" className="h-10 shrink-0" onClick={addWebhook}>
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="h-10 shrink-0"
|
|
||||||
onClick={addWebhook}
|
|
||||||
>
|
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
<span className="ms-1">
|
<span className="ms-1">
|
||||||
<Trans>Add URL</Trans>
|
<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 ShoutrrrUrlCard = ({ url, onUrlChange, onRemove }: ShoutrrrUrlCardProps) => {
|
||||||
const [isLoading, setIsLoading] = useState(false)
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
const sendTestNotification = async () => {
|
const sendTestNotification = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
try {
|
||||||
if ("err" in res && !res.err) {
|
const res = await pb.send("/api/beszel/test-notification", { method: "POST", body: { url } })
|
||||||
toast({
|
if ("err" in res && !res.err) {
|
||||||
title: t`Test notification sent`,
|
toast({
|
||||||
description: t`Check your notification service`,
|
title: t`Test notification sent`,
|
||||||
})
|
description: t`Check your notification service`,
|
||||||
} else {
|
})
|
||||||
toast({
|
} else {
|
||||||
title: t`Error`,
|
showTestNotificationError(res.err)
|
||||||
description: res.err ?? t`Failed to send test notification`,
|
}
|
||||||
variant: "destructive",
|
} catch (e: unknown) {
|
||||||
})
|
showTestNotificationError((e as ClientResponseError).data?.message)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
setIsLoading(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { memo, useState } from "react"
|
import { memo, useState } from "react"
|
||||||
import { Trans } from "@lingui/react/macro"
|
import { Trans } from "@lingui/react/macro"
|
||||||
import { compareSemVer, parseSemVer } from "@/lib/utils"
|
import { compareSemVer, parseSemVer } from "@/lib/utils"
|
||||||
|
|
||||||
import type { GPUData } from "@/types"
|
import type { GPUData } from "@/types"
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import InfoBar from "./system/info-bar"
|
import InfoBar from "./system/info-bar"
|
||||||
import { useSystemData } from "./system/use-system-data"
|
import { useSystemData } from "./system/use-system-data"
|
||||||
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
|
import { CpuChart, ContainerCpuChart } from "./system/charts/cpu-charts"
|
||||||
import { MemoryChart, ContainerMemoryChart, SwapChart } from "./system/charts/memory-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 { BandwidthChart, ContainerNetworkChart } from "./system/charts/network-charts"
|
||||||
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
import { TemperatureChart, BatteryChart } from "./system/charts/sensor-charts"
|
||||||
import { GpuPowerChart, GpuDetailCharts } from "./system/charts/gpu-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 } from "./system/lazy-tables"
|
||||||
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
import { LoadAverageChart } from "./system/charts/load-average-chart"
|
||||||
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
import { ContainerIcon, CpuIcon, HardDriveIcon, TerminalSquareIcon } from "lucide-react"
|
||||||
@@ -24,6 +22,8 @@ const SEMVER_0_14_0 = parseSemVer("0.14.0")
|
|||||||
const SEMVER_0_15_0 = parseSemVer("0.15.0")
|
const SEMVER_0_15_0 = parseSemVer("0.15.0")
|
||||||
|
|
||||||
export default memo(function SystemDetail({ id }: { id: string }) {
|
export default memo(function SystemDetail({ id }: { id: string }) {
|
||||||
|
const systemData = useSystemData(id)
|
||||||
|
|
||||||
const {
|
const {
|
||||||
system,
|
system,
|
||||||
systemStats,
|
systemStats,
|
||||||
@@ -48,7 +48,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
hasGpuData,
|
hasGpuData,
|
||||||
hasGpuEnginesData,
|
hasGpuEnginesData,
|
||||||
hasGpuPowerData,
|
hasGpuPowerData,
|
||||||
} = useSystemData(id)
|
} = systemData
|
||||||
|
|
||||||
// extra margin to add to bottom of page, specifically for temperature chart,
|
// 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
|
// where the tooltip can go past the bottom of the page if lots of sensors
|
||||||
@@ -103,7 +103,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DiskCharts {...coreProps} systemStats={systemStats} />
|
<RootDiskCharts systemData={systemData} />
|
||||||
|
|
||||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExtraFsCharts {...coreProps} systemStats={systemStats} />
|
<ExtraFsCharts systemData={systemData} />
|
||||||
|
|
||||||
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
||||||
|
|
||||||
@@ -188,6 +188,7 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
<LoadAverageChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} />
|
||||||
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
<BandwidthChart {...coreProps} systemStats={systemStats} />
|
||||||
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
<TemperatureChart {...coreProps} setPageBottomExtraMargin={setPageBottomExtraMargin} />
|
||||||
|
<BatteryChart {...coreProps} />
|
||||||
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
<SwapChart chartData={chartData} grid={grid} dataEmpty={dataEmpty} systemStats={systemStats} />
|
||||||
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
{pageBottomExtraMargin > 0 && <div style={{ marginBottom: pageBottomExtraMargin }}></div>}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,9 +198,9 @@ export default memo(function SystemDetail({ id }: { id: string }) {
|
|||||||
{mountedTabs.has("disk") && (
|
{mountedTabs.has("disk") && (
|
||||||
<>
|
<>
|
||||||
<div className="grid xl:grid-cols-2 gap-4">
|
<div className="grid xl:grid-cols-2 gap-4">
|
||||||
<DiskCharts {...coreProps} systemStats={systemStats} />
|
<RootDiskCharts systemData={systemData} />
|
||||||
</div>
|
</div>
|
||||||
<ExtraFsCharts {...coreProps} systemStats={systemStats} />
|
<ExtraFsCharts systemData={systemData} />
|
||||||
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
{maybeHasSmartData && <LazySmartTable systemId={system.id} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,106 +1,283 @@
|
|||||||
import { t } from "@lingui/core/macro"
|
import { t } from "@lingui/core/macro"
|
||||||
import AreaChartDefault from "@/components/charts/area-chart"
|
import AreaChartDefault from "@/components/charts/area-chart"
|
||||||
import { $userSettings } from "@/lib/stores"
|
|
||||||
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
|
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 { ChartCard, SelectAvgMax } from "../chart-card"
|
||||||
import { Unit } from "@/lib/enums"
|
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({
|
// Helpers for indexed dios/diosm access
|
||||||
chartData,
|
const dios =
|
||||||
grid,
|
(i: number) =>
|
||||||
dataEmpty,
|
({ stats }: SystemStatsRecord) =>
|
||||||
showMax,
|
stats?.dios?.[i] ?? 0
|
||||||
isLongerChart,
|
const diosMax =
|
||||||
maxValues,
|
(i: number) =>
|
||||||
}: {
|
({ stats }: SystemStatsRecord) =>
|
||||||
chartData: ChartData
|
stats?.diosm?.[i] ?? 0
|
||||||
grid: boolean
|
const extraDios =
|
||||||
dataEmpty: boolean
|
(name: string, i: number) =>
|
||||||
showMax: boolean
|
({ stats }: SystemStatsRecord) =>
|
||||||
isLongerChart: boolean
|
stats?.efs?.[name]?.dios?.[i] ?? 0
|
||||||
maxValues: boolean
|
const extraDiosMax =
|
||||||
systemStats: SystemStatsRecord[]
|
(name: string, i: number) =>
|
||||||
}) {
|
({ stats }: SystemStatsRecord) =>
|
||||||
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
stats?.efs?.[name]?.diosm?.[i] ?? 0
|
||||||
const userSettings = $userSettings.get()
|
|
||||||
|
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
|
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
|
// round to nearest GB
|
||||||
if (diskSize >= 100) {
|
if (diskSize >= 100) {
|
||||||
diskSize = Math.round(diskSize)
|
diskSize = Math.round(diskSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 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>
|
|
||||||
|
|
||||||
<ChartCard
|
return (
|
||||||
empty={dataEmpty}
|
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description}>
|
||||||
grid={grid}
|
<AreaChartDefault
|
||||||
title={t`Disk I/O`}
|
chartData={chartData}
|
||||||
description={t`Throughput of root filesystem`}
|
domain={[0, diskSize]}
|
||||||
cornerEl={maxValSelect}
|
tickFormatter={(val) => {
|
||||||
>
|
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
|
||||||
<AreaChartDefault
|
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
||||||
chartData={chartData}
|
}}
|
||||||
maxToggled={showMax}
|
contentFormatter={({ value }) => {
|
||||||
dataPoints={[
|
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
|
||||||
{
|
return `${decimalString(convertedValue)} ${unit}`
|
||||||
label: t({ message: "Write", comment: "Disk write" }),
|
}}
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => {
|
dataPoints={[
|
||||||
if (showMax) {
|
{
|
||||||
return stats?.dio?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024
|
label: t`Disk Usage`,
|
||||||
}
|
color: 4,
|
||||||
return stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024
|
opacity: 0.4,
|
||||||
},
|
dataKey: extraFsName ? diskDataFns.extraUsage(extraFsName) : diskDataFns.usage,
|
||||||
color: 3,
|
},
|
||||||
opacity: 0.3,
|
]}
|
||||||
},
|
></AreaChartDefault>
|
||||||
{
|
</ChartCard>
|
||||||
label: t({ message: "Read", comment: "Disk read" }),
|
)
|
||||||
dataKey: ({ stats }: SystemStatsRecord) => {
|
}
|
||||||
if (showMax) {
|
|
||||||
return stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024
|
export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
|
||||||
}
|
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
|
||||||
return stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024
|
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
|
||||||
},
|
const userSettings = useStore($userSettings)
|
||||||
color: 1,
|
|
||||||
opacity: 0.3,
|
if (!chartData.systemStats?.length) {
|
||||||
},
|
return null
|
||||||
]}
|
}
|
||||||
tickFormatter={(val) => {
|
|
||||||
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
|
const title = extraFsName ? `${extraFsName} I/O` : t`Disk I/O`
|
||||||
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
|
const description = extraFsName ? t`Throughput of ${extraFsName}` : t`Throughput of root filesystem`
|
||||||
}}
|
|
||||||
contentFormatter={({ value }) => {
|
const hasMoreIOMetrics = chartData.systemStats?.some((record) => record.stats?.dios?.at(0))
|
||||||
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
|
|
||||||
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
|
let CornerEl = maxValSelect
|
||||||
}}
|
if (hasMoreIOMetrics) {
|
||||||
showTotal={true}
|
CornerEl = (
|
||||||
/>
|
<div className="flex gap-2">
|
||||||
</ChartCard>
|
{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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
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>
|
||||||
|
)
|
||||||
|
})
|
||||||
@@ -36,7 +36,7 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { pb } from "@/lib/api"
|
import { isReadOnlyUser, pb } from "@/lib/api"
|
||||||
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -492,7 +492,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
|
|||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
const columns = createColumns(longestName, longestModel, longestDevice)
|
const columns = createColumns(longestName, longestModel, longestDevice)
|
||||||
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
|
||||||
return [...baseColumns, actionColumn]
|
return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
|
||||||
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import type {
|
|||||||
import { $router, navigate } from "../../router"
|
import { $router, navigate } from "../../router"
|
||||||
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
|
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
|
||||||
|
|
||||||
|
export type SystemData = ReturnType<typeof useSystemData>
|
||||||
|
|
||||||
export function useSystemData(id: string) {
|
export function useSystemData(id: string) {
|
||||||
const direction = useStore($direction)
|
const direction = useStore($direction)
|
||||||
const systems = useStore($systems)
|
const systems = useStore($systems)
|
||||||
@@ -190,7 +192,7 @@ export function useSystemData(id: string) {
|
|||||||
|
|
||||||
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
|
// 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
|
const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined
|
||||||
if (lastCreated && Date.now() - lastCreated < expectedInterval) {
|
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { listenKeys } from "nanostores"
|
|||||||
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
|
||||||
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
|
||||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
|
||||||
import { Card, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
|
||||||
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
|
||||||
@@ -161,13 +161,13 @@ export default function SystemdTable({ systemId }: { systemId?: string }) {
|
|||||||
<CardTitle className="mb-2">
|
<CardTitle className="mb-2">
|
||||||
<Trans>Systemd Services</Trans>
|
<Trans>Systemd Services</Trans>
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="flex items-center">
|
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
|
||||||
<Trans>Total: {data.length}</Trans>
|
<Trans>Total: {data.length}</Trans>
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
|
||||||
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
|
||||||
<Trans>Updated every 10 minutes.</Trans>
|
<Trans>Updated every 10 minutes.</Trans>
|
||||||
</CardDescription>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
placeholder={t`Filter...`}
|
placeholder={t`Filter...`}
|
||||||
|
|||||||
@@ -110,20 +110,23 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
|
|||||||
|
|
||||||
// match filter value against name or translated status
|
// match filter value against name or translated status
|
||||||
return (row, _, newFilterInput) => {
|
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) {
|
if (newFilterInput !== filterInput) {
|
||||||
filterInput = newFilterInput
|
filterInput = newFilterInput
|
||||||
filterInputLower = newFilterInput.toLowerCase()
|
filterInputLower = newFilterInput.toLowerCase()
|
||||||
}
|
}
|
||||||
let nameLower = nameCache.get(name)
|
let nameLower = nameCache.get(sys.name)
|
||||||
if (nameLower === undefined) {
|
if (nameLower === undefined) {
|
||||||
nameLower = name.toLowerCase()
|
nameLower = sys.name.toLowerCase()
|
||||||
nameCache.set(name, nameLower)
|
nameCache.set(sys.name, nameLower)
|
||||||
}
|
}
|
||||||
if (nameLower.includes(filterInputLower)) {
|
if (nameLower.includes(filterInputLower)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
const statusLower = statusTranslations[status as keyof typeof statusTranslations]
|
const statusLower = statusTranslations[sys.status as keyof typeof statusTranslations]
|
||||||
return statusLower?.includes(filterInputLower) || false
|
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">
|
<CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
|
||||||
<div className="flex items-center gap-2 w-full overflow-hidden">
|
<div className="flex items-center gap-1 w-full overflow-hidden">
|
||||||
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
|
<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">
|
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
<IndicatorDot system={system} />
|
<IndicatorDot system={system} />
|
||||||
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
|
||||||
</div>
|
</div>
|
||||||
</CardTitle>
|
</h3>
|
||||||
{table.getColumn("actions")?.getIsVisible() && (
|
{table.getColumn("actions")?.getIsVisible() && (
|
||||||
<div className="flex gap-1 shrink-0 relative z-10">
|
<div className="flex gap-1 shrink-0 relative z-10">
|
||||||
<AlertButton system={system} />
|
<AlertButton system={system} />
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ const AlertDialogContent = React.forwardRef<
|
|||||||
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
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"
|
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,7 @@ CardHeader.displayName = "CardHeader"
|
|||||||
|
|
||||||
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<h3
|
<h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn("text-[1.4em] sm:text-2xl font-semibold leading-none tracking-tight", className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
CardTitle.displayName = "CardTitle"
|
CardTitle.displayName = "CardTitle"
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
|
|||||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
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"
|
DialogHeader.displayName = "DialogHeader"
|
||||||
|
|
||||||
|
|||||||
@@ -177,6 +177,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@utility text-card-title {
|
||||||
|
@apply text-[1.4rem] sm:text-2xl;
|
||||||
|
}
|
||||||
|
|
||||||
.recharts-tooltip-wrapper {
|
.recharts-tooltip-wrapper {
|
||||||
z-index: 51;
|
z-index: 51;
|
||||||
@apply tabular-nums;
|
@apply tabular-nums;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: ar\n"
|
"Language: ar\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Arabic\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"
|
"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
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} متاح"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "المتوسط ينخفض أقل من <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "المتوسط يتجاوز <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
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "متوسط استهلاك طاقة وحدة معالجة الرسوميات"
|
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
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "متوسط استخدام وحدة المعالجة المركزية على مستوى النظام"
|
msgstr "متوسط استخدام وحدة المعالجة المركزية على مستوى النظام"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "نسخ متغيرات البيئة"
|
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
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "نسخ المضيف"
|
msgstr "نسخ المضيف"
|
||||||
@@ -476,7 +490,7 @@ msgstr "نسخ YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "النواة"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "يوميًا"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "افتراضي"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
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/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "استخدام القرص"
|
msgstr "استخدام القرص"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "استخدام القرص لـ {extraFsName}"
|
msgstr "استخدام القرص لـ {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "عرض"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "طريقة HTTP"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "طريقة HTTP: POST، GET، أو HEAD (الافتراضي: 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
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "تنسيق الحمولة"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "متوسط الاستخدام لكل نواة"
|
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
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
|
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "المنفذ"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "المنافذ"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "تم بدء العملية"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "المفتاح العام"
|
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
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "ساعات الهدوء"
|
msgstr "ساعات الهدوء"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "قراءة"
|
msgstr "قراءة"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "جدول"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "تبويبات"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذل
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة من قاعدة البيانات بشكل دائم."
|
msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة من قاعدة البيانات بشكل دائم."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "معدل نقل {extraFsName}"
|
msgstr "معدل نقل {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "إجمالي البيانات المستلمة لكل واجهة"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "إجمالي البيانات المرسلة لكل واجهة"
|
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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "رفع"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "مدة التشغيل"
|
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
|
#: 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"
|
msgid "Users"
|
||||||
msgstr "المستخدمون"
|
msgstr "المستخدمون"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "الاستخدام"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "القيمة"
|
msgstr "القيمة"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "عرض"
|
msgstr "عرض"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "عرض المزيد"
|
msgstr "عرض المزيد"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "أمر ويندوز"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "كتابة"
|
msgstr "كتابة"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: bg\n"
|
"Language: bg\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
"PO-Revision-Date: 2026-03-28 09:32\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Bulgarian\n"
|
"Language-Team: Bulgarian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "Версия {0} е налична"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "Средната стойност пада под <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Средната стойност надхвърля <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 "Среден брой I/O операции, чакащи обслужване"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Средна консумация на ток от графични карти"
|
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
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Средно използване на процесора на цялата система"
|
msgstr "Средно използване на процесора на цялата система"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Копирай еnv"
|
msgstr "Копирай еnv"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Копиране от"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Копирай хоста"
|
msgstr "Копирай хоста"
|
||||||
@@ -476,7 +490,7 @@ msgstr "Копирай YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Основни"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Дневно"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Подредба"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
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/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Използване на диск"
|
msgstr "Използване на диск"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Изполване на диск от {extraFsName}"
|
msgstr "Изполване на диск от {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Показване"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP метод"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP метод: POST, GET или HEAD (по подразбиране: 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 "I/O изчакване"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O време"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O натоварване"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Формат на полезния товар"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Средно използване на ядро"
|
msgstr "Средно използване на ядро"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Процент от времето, в което дискът е зает с I/O операции"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Процент време, прекарано във всяко състояние"
|
msgstr "Процент време, прекарано във всяко състояние"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "Порт"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Портове"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Процесът стартира"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Публичен ключ"
|
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
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Тихи часове"
|
msgstr "Тихи часове"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Прочети"
|
msgstr "Прочети"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Таблица"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Табове"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Това действие не може да бъде отменено.
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Това ще доведе до трайно изтриване на всички избрани записи от базата данни."
|
msgstr "Това ще доведе до трайно изтриване на всички избрани записи от базата данни."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Пропускателна способност на {extraFsName}"
|
msgstr "Пропускателна способност на {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Общо получени данни за всеки интерфейс"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Общо изпратени данни за всеки интерфейс"
|
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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Качване"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Време на работа"
|
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
|
#: 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"
|
msgid "Users"
|
||||||
msgstr "Потребители"
|
msgstr "Потребители"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Натоварване"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Стойност"
|
msgstr "Стойност"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Изглед"
|
msgstr "Изглед"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Виж повече"
|
msgstr "Виж повече"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Команда Windows"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Запиши"
|
msgstr "Запиши"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: cs\n"
|
"Language: cs\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Czech\n"
|
"Language-Team: Czech\n"
|
||||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} k dispozici"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "Průměr klesne pod <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Průměr je vyšší než <0>{value}{0}</0>"
|
msgstr "Průměr je vyšší než <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Průměrný počet I/O operací čekajících na vyřízení"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Průměrná spotřeba energie GPU"
|
msgstr "Průměrná spotřeba energie GPU"
|
||||||
|
|
||||||
|
#: 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 "Průměrná doba od zařazení do fronty po dokončení operace"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Průměrné využití CPU v celém systému"
|
msgstr "Průměrné využití CPU v celém systému"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Kopírovat env"
|
msgstr "Kopírovat env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Kopírovat z"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Kopírovat hostitele"
|
msgstr "Kopírovat hostitele"
|
||||||
@@ -476,7 +490,7 @@ msgstr "Kopírovat YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Jádro"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Denně"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Výchozí"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,19 +613,18 @@ msgstr "Disková jednotka"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Využití disku"
|
msgstr "Využití disku"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Využití disku {extraFsName}"
|
msgstr "Využití disku {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Zobrazení"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP metoda"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP metoda: POST, GET nebo HEAD (výchozí: POST)"
|
msgstr "HTTP metoda: POST, GET nebo HEAD (výchozí: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "I/O Čekání"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O Čas"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O Využití"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Formát payloadu"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Průměrné využití na jádro"
|
msgstr "Průměrné využití na jádro"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Procento času, po který je disk zaneprázdněn I/O operacemi"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Procento času strávěného v každém stavu"
|
msgstr "Procento času strávěného v každém stavu"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Porty"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Proces spuštěn"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Veřejný klíč"
|
msgstr "Veřejný klíč"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Hloubka fronty"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Tiché hodiny"
|
msgstr "Tiché hodiny"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Číst"
|
msgstr "Číst"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tabulka"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Karty"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Tuto akci nelze vzít zpět. Tím se z databáze trvale odstraní všech
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Tímto trvale odstraníte všechny vybrané záznamy z databáze."
|
msgstr "Tímto trvale odstraníte všechny vybrané záznamy z databáze."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Propustnost {extraFsName}"
|
msgstr "Propustnost {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Celkový přijatý objem dat pro každé rozhraní"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Celkový odeslaný objem dat pro každé rozhraní"
|
msgstr "Celkový odeslaný objem dat pro každé rozhraní"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Odeslání"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Doba provozu"
|
msgstr "Doba provozu"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Využito"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Uživatelé"
|
msgstr "Uživatelé"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Využití"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Hodnota"
|
msgstr "Hodnota"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Zobrazení"
|
msgstr "Zobrazení"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Zobrazit více"
|
msgstr "Zobrazit více"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Windows příkaz"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Psát"
|
msgstr "Psát"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: da\n"
|
"Language: da\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Danish\n"
|
"Language-Team: Danish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} tilgængelig"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "Gennemsnit falder under <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Gennemsnittet overstiger <0>{value}{0}</0>"
|
msgstr "Gennemsnittet overstiger <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Gennemsnitligt antal I/O-operationer, der venter på at blive betjent"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Gennemsnitligt strømforbrug for GPU'er"
|
msgstr "Gennemsnitligt strømforbrug for GPU'er"
|
||||||
|
|
||||||
|
#: 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 "Gennemsnitlig tid fra kø til færdiggørelse pr. operation"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Gennemsnitlig systembaseret CPU-udnyttelse"
|
msgstr "Gennemsnitlig systembaseret CPU-udnyttelse"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Kopier miljø"
|
msgstr "Kopier miljø"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Kopier fra"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Kopier vært"
|
msgstr "Kopier vært"
|
||||||
@@ -476,7 +490,7 @@ msgstr "Kopier YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Kerne"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Dagligt"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Standard"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,19 +613,18 @@ msgstr "Diskenhed"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Diskforbrug"
|
msgstr "Diskforbrug"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Diskforbrug af {extraFsName}"
|
msgstr "Diskforbrug af {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Visning"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -870,7 +883,7 @@ msgstr "Sundhed"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Hjerteslag"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP-metode"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP-metode: POST, GET eller HEAD (standard: POST)"
|
msgstr "HTTP-metode: POST, GET eller HEAD (standard: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "I/O Vent"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O Tid"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O Udnyttelse"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Payload-format"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Gennemsnitlig udnyttelse pr. kerne"
|
msgstr "Gennemsnitlig udnyttelse pr. kerne"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Procentdel af tiden disk-en er optaget af I/O"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Procentdel af tid brugt i hver tilstand"
|
msgstr "Procentdel af tid brugt i hver tilstand"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Porte"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Proces startet"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Offentlig nøgle"
|
msgstr "Offentlig nøgle"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Kødybde"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Stille timer"
|
msgstr "Stille timer"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Læs"
|
msgstr "Læs"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tabel"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Faner"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Denne handling kan ikke fortrydes. Dette vil permanent slette alle aktue
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Dette vil permanent slette alle poster fra databasen."
|
msgstr "Dette vil permanent slette alle poster fra databasen."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Gennemløb af {extraFsName}"
|
msgstr "Gennemløb af {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Samlet modtaget data for hver interface"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Samlet sendt data for hver interface"
|
msgstr "Samlet sendt data for hver interface"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Overfør"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Oppetid"
|
msgstr "Oppetid"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Brugt"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Brugere"
|
msgstr "Brugere"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Udnyttelse"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Værdi"
|
msgstr "Værdi"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Vis"
|
msgstr "Vis"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Se mere"
|
msgstr "Se mere"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Windows-kommando"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Skriv"
|
msgstr "Skriv"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: de\n"
|
"Language: de\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-03-14 20:37\n"
|
"PO-Revision-Date: 2026-04-05 18:27\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: German\n"
|
"Language-Team: German\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} verfügbar"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "Durchschnitt unterschreitet <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Durchschnitt überschreitet <0>{value}{0}</0>"
|
msgstr "Durchschnitt überschreitet <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Durchschnittliche Anzahl der auf Bearbeitung wartenden I/O-Operationen"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Durchschnittlicher Stromverbrauch der GPUs"
|
msgstr "Durchschnittlicher Stromverbrauch der GPUs"
|
||||||
|
|
||||||
|
#: 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 "Durchschnittliche Warteschlangen- bis Abschlusszeit pro Operation"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Durchschnittliche systemweite CPU-Auslastung"
|
msgstr "Durchschnittliche systemweite CPU-Auslastung"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Umgebungsvariablen kopieren"
|
msgstr "Umgebungsvariablen kopieren"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Kopieren von"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Host kopieren"
|
msgstr "Host kopieren"
|
||||||
@@ -476,7 +490,7 @@ msgstr "YAML kopieren"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Kern"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Täglich"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Standard"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,19 +613,18 @@ msgstr "Festplatteneinheit"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Festplattennutzung"
|
msgstr "Festplattennutzung"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Festplattennutzung von {extraFsName}"
|
msgstr "Festplattennutzung von {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Anzeige"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP-Methode"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP-Methode: POST, GET oder HEAD (Standard: POST)"
|
msgstr "HTTP-Methode: POST, GET oder HEAD (Standard: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "I/O-Wartezeit"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O-Zeit"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O-Auslastung"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Payload-Format"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Durchschnittliche Auslastung pro Kern"
|
msgstr "Durchschnittliche Auslastung pro Kern"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Prozentsatz der Zeit, in der die Festplatte mit I/O beschäftigt ist"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Prozentsatz der Zeit in jedem Zustand"
|
msgstr "Prozentsatz der Zeit in jedem Zustand"
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Prozess gestartet"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Öffentlicher Schlüssel"
|
msgstr "Öffentlicher Schlüssel"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Warteschlangentiefe"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Ruhezeiten"
|
msgstr "Ruhezeiten"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Lesen"
|
msgstr "Lesen"
|
||||||
|
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Dadurch werden alle ausgewählten Datensätze dauerhaft aus der Datenbank gelöscht."
|
msgstr "Dadurch werden alle ausgewählten Datensätze dauerhaft aus der Datenbank gelöscht."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Durchsatz von {extraFsName}"
|
msgstr "Durchsatz von {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Empfangene Gesamtdatenmenge je Schnittstelle "
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Gesendete Gesamtdatenmenge je Schnittstelle"
|
msgstr "Gesendete Gesamtdatenmenge je Schnittstelle"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O"
|
||||||
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
|
msgstr "Gesamtzeit für Lese-/Schreibvorgänge (kann 100% überschreiten)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Hochladen"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Betriebszeit"
|
msgstr "Betriebszeit"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Verwendet"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Auslastung"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Wert"
|
msgstr "Wert"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Ansicht"
|
msgstr "Ansicht"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Mehr anzeigen"
|
msgstr "Mehr anzeigen"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Windows-Befehl"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Schreiben"
|
msgstr "Schreiben"
|
||||||
|
|
||||||
|
|||||||
@@ -204,10 +204,19 @@ msgstr "Average drops below <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Average exceeds <0>{value}{0}</0>"
|
msgstr "Average exceeds <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Average number of I/O operations waiting to be serviced"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Average power consumption of GPUs"
|
msgstr "Average power consumption of GPUs"
|
||||||
|
|
||||||
|
#: 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 "Average queue to completion time per operation"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Average system-wide CPU utilization"
|
msgstr "Average system-wide CPU utilization"
|
||||||
@@ -439,6 +448,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Copy env"
|
msgstr "Copy env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Copy from"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Copy host"
|
msgstr "Copy host"
|
||||||
@@ -594,12 +608,11 @@ msgstr "Disk unit"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Disk Usage"
|
msgstr "Disk Usage"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Disk usage of {extraFsName}"
|
msgstr "Disk usage of {extraFsName}"
|
||||||
|
|
||||||
@@ -893,6 +906,21 @@ msgstr "HTTP Method"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgstr "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "I/O Await"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O Time"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O Utilization"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1202,6 +1230,10 @@ msgstr "Payload format"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Per-core average utilization"
|
msgstr "Per-core average utilization"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Percent of time the disk is busy with I/O"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Percentage of time spent in each state"
|
msgstr "Percentage of time spent in each state"
|
||||||
@@ -1279,13 +1311,20 @@ msgstr "Process started"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Public Key"
|
msgstr "Public Key"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Queue Depth"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Quiet Hours"
|
msgstr "Quiet Hours"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Read"
|
msgstr "Read"
|
||||||
|
|
||||||
@@ -1597,7 +1636,7 @@ msgstr "This action cannot be undone. This will permanently delete all current r
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "This will permanently delete all selected records from the database."
|
msgstr "This will permanently delete all selected records from the database."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Throughput of {extraFsName}"
|
msgstr "Throughput of {extraFsName}"
|
||||||
|
|
||||||
@@ -1650,6 +1689,11 @@ msgstr "Total data received for each interface"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Total data sent for each interface"
|
msgstr "Total data sent for each interface"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O"
|
||||||
|
msgid "Total time spent on read/write (can exceed 100%)"
|
||||||
|
msgstr "Total time spent on read/write (can exceed 100%)"
|
||||||
|
|
||||||
#. placeholder {0}: data.length
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1770,7 +1814,7 @@ msgstr "Upload"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Uptime"
|
msgstr "Uptime"
|
||||||
|
|
||||||
#: 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
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
@@ -1792,6 +1836,11 @@ msgstr "Used"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Users"
|
msgstr "Users"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Utilization"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Value"
|
msgstr "Value"
|
||||||
@@ -1801,6 +1850,7 @@ msgid "View"
|
|||||||
msgstr "View"
|
msgstr "View"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "View more"
|
msgstr "View more"
|
||||||
@@ -1853,7 +1903,9 @@ msgstr "Windows command"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Write"
|
msgstr "Write"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: es\n"
|
"Language: es\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Spanish\n"
|
"Language-Team: Spanish\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} disponible"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "El promedio cae por debajo de <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "El promedio excede <0>{value}{0}</0>"
|
msgstr "El promedio excede <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Número medio de operaciones de E/S en espera de ser atendidas"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Consumo de energía promedio de GPUs"
|
msgstr "Consumo de energía promedio de GPUs"
|
||||||
|
|
||||||
|
#: 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 "Tiempo medio de cola hasta la finalización por operación"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Utilización promedio de CPU del sistema"
|
msgstr "Utilización promedio de CPU del sistema"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Copiar env"
|
msgstr "Copiar env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Copiar de"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Copiar host"
|
msgstr "Copiar host"
|
||||||
@@ -476,7 +490,7 @@ msgstr "Copiar YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Núcleo"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Diariamente"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Predeterminado"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,19 +613,18 @@ msgstr "Unidad de disco"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Uso de disco"
|
msgstr "Uso de disco"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Uso de disco de {extraFsName}"
|
msgstr "Uso de disco de {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Pantalla"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -870,7 +883,7 @@ msgstr "Estado"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Latido"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -898,6 +911,21 @@ msgstr "Método HTTP"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "Método HTTP: POST, GET o HEAD (predeterminado: POST)"
|
msgstr "Método HTTP: POST, GET o HEAD (predeterminado: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "Espera de E/S"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "Tiempo de E/S"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "Utilización de E/S"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Formato de carga útil (payload)"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Uso promedio por núcleo"
|
msgstr "Uso promedio por núcleo"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Porcentaje de tiempo que el disco está ocupado con E/S"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Porcentaje de tiempo dedicado a cada estado"
|
msgstr "Porcentaje de tiempo dedicado a cada estado"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "Puerto"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Puertos"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Proceso iniciado"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Clave pública"
|
msgstr "Clave pública"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Profundidad de cola"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Horas de silencio"
|
msgstr "Horas de silencio"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Lectura"
|
msgstr "Lectura"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tabla"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Pestañas"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Esta acción no se puede deshacer. Esto eliminará permanentemente todos
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Esto eliminará permanentemente todos los registros seleccionados de la base de datos."
|
msgstr "Esto eliminará permanentemente todos los registros seleccionados de la base de datos."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Rendimiento de {extraFsName}"
|
msgstr "Rendimiento de {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Datos totales recibidos por cada interfaz"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Datos totales enviados por cada interfaz"
|
msgstr "Datos totales enviados por cada interfaz"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Cargar"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Tiempo de actividad"
|
msgstr "Tiempo de actividad"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Usado"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Usuarios"
|
msgstr "Usuarios"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Utilización"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Valor"
|
msgstr "Valor"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Vista"
|
msgstr "Vista"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Ver más"
|
msgstr "Ver más"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Comando Windows"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Escritura"
|
msgstr "Escritura"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: fa\n"
|
"Language: fa\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
"PO-Revision-Date: 2026-04-05 18:28\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Persian\n"
|
"Language-Team: Persian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} در دسترس است"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "میانگین به زیر <0>{value}{0}</0> میافتد"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "میانگین از <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
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "میانگین مصرف برق پردازندههای گرافیکی"
|
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
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "میانگین استفاده از CPU در کل سیستم"
|
msgstr "میانگین استفاده از CPU در کل سیستم"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "کپی متغیرهای محیط"
|
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
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "کپی میزبان"
|
msgstr "کپی میزبان"
|
||||||
@@ -476,7 +490,7 @@ msgstr "کپی YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "هسته"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "روزانه"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "پیشفرض"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
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/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "میزان استفاده از دیسک"
|
msgstr "میزان استفاده از دیسک"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "میزان استفاده از دیسک {extraFsName}"
|
msgstr "میزان استفاده از دیسک {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "نمایش"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "متد HTTP"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "متد HTTP: POST، GET، یا HEAD (پیشفرض: 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
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "فرمت پیلود"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "میانگین استفاده در هر هسته"
|
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
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "درصد زمان صرف شده در هر حالت"
|
msgstr "درصد زمان صرف شده در هر حالت"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "پورت"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "پورتها"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "فرآیند شروع شد"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "کلید عمومی"
|
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
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "ساعات آرام"
|
msgstr "ساعات آرام"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "خواندن"
|
msgstr "خواندن"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "جدول"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "تبها"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "این عمل قابل برگشت نیست. این کار تمام رک
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "این کار تمام رکوردهای انتخاب شده را برای همیشه از پایگاه داده حذف خواهد کرد."
|
msgstr "این کار تمام رکوردهای انتخاب شده را برای همیشه از پایگاه داده حذف خواهد کرد."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "توان عملیاتی {extraFsName}"
|
msgstr "توان عملیاتی {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "دادههای کل دریافت شده برای هر رابط"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "دادههای کل ارسال شده برای هر رابط"
|
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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "آپلود"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "آپتایم"
|
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
|
#: 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"
|
msgid "Users"
|
||||||
msgstr "کاربران"
|
msgstr "کاربران"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "بهرهوری"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "مقدار"
|
msgstr "مقدار"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "مشاهده"
|
msgstr "مشاهده"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "مشاهده بیشتر"
|
msgstr "مشاهده بیشتر"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "دستور Windows"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "نوشتن"
|
msgstr "نوشتن"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: fr\n"
|
"Language: fr\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: French\n"
|
"Language-Team: French\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} disponible"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "La moyenne descend en dessous de <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "La moyenne dépasse <0>{value}{0}</0>"
|
msgstr "La moyenne dépasse <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Nombre moyen d'opérations d'E/S en attente d'être traitées"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Consommation d'énergie moyenne des GPUs"
|
msgstr "Consommation d'énergie moyenne des GPUs"
|
||||||
|
|
||||||
|
#: 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 "Temps moyen de file d'attente jusqu'à l'achèvement par opération"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Utilisation moyenne du CPU à l'échelle du système"
|
msgstr "Utilisation moyenne du CPU à l'échelle du système"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Copier env"
|
msgstr "Copier env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Copier depuis"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Copier l'hôte"
|
msgstr "Copier l'hôte"
|
||||||
@@ -476,7 +490,7 @@ msgstr "Copier YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Cœur"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Quotidien"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Par défaut"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,19 +613,18 @@ msgstr "Unité disque"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Utilisation du disque"
|
msgstr "Utilisation du disque"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Utilisation du disque de {extraFsName}"
|
msgstr "Utilisation du disque de {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Affichage"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -870,7 +883,7 @@ msgstr "Santé"
|
|||||||
|
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
msgid "Heartbeat"
|
msgid "Heartbeat"
|
||||||
msgstr ""
|
msgstr "Pulsation"
|
||||||
|
|
||||||
#: src/components/routes/settings/heartbeat.tsx
|
#: src/components/routes/settings/heartbeat.tsx
|
||||||
msgid "Heartbeat Monitoring"
|
msgid "Heartbeat Monitoring"
|
||||||
@@ -898,6 +911,21 @@ msgstr "Méthode HTTP"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "Méthode HTTP : POST, GET ou HEAD (par défaut : POST)"
|
msgstr "Méthode HTTP : POST, GET ou HEAD (par défaut : POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "Attente E/S"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "Temps E/S"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "Utilisation E/S"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Format de la charge utile"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Utilisation moyenne par cœur"
|
msgstr "Utilisation moyenne par cœur"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Pourcentage de temps pendant lequel le disque est occupé par des E/S"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Pourcentage de temps passé dans chaque état"
|
msgstr "Pourcentage de temps passé dans chaque état"
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Processus démarré"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Clé publique"
|
msgstr "Clé publique"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Profondeur de file d'attente"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Heures calmes"
|
msgstr "Heures calmes"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Lecture"
|
msgstr "Lecture"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tableau"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Onglets"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Cette action ne peut pas être annulée. Cela supprimera définitivement
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Ceci supprimera définitivement tous les enregistrements sélectionnés de la base de données."
|
msgstr "Ceci supprimera définitivement tous les enregistrements sélectionnés de la base de données."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Débit de {extraFsName}"
|
msgstr "Débit de {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Données totales reçues pour chaque interface"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Données totales envoyées pour chaque interface"
|
msgstr "Données totales envoyées pour chaque interface"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Téléverser"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Temps de fonctionnement"
|
msgstr "Temps de fonctionnement"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Utilisé"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Utilisateurs"
|
msgstr "Utilisateurs"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Utilisation"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Valeur"
|
msgstr "Valeur"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Vue"
|
msgstr "Vue"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Voir plus"
|
msgstr "Voir plus"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Commande Windows"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Écriture"
|
msgstr "Écriture"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: he\n"
|
"Language: he\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Hebrew\n"
|
"Language-Team: Hebrew\n"
|
||||||
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
|
"Plural-Forms: nplurals=4; plural=n%100==1 ? 0 : n%100==2 ? 1 : n%100==3 || n%100==4 ? 2 : 3;\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} זמין"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "הממוצע יורד מתחת ל-<0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "הממוצע עולה על <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
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "צריכת חשמל ממוצעת של GPUs"
|
msgstr "צריכת חשמל ממוצעת של GPUs"
|
||||||
|
|
||||||
|
#: 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
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "ניצול ממוצע כלל-מערכתי של CPU"
|
msgstr "ניצול ממוצע כלל-מערכתי של CPU"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "העתק env"
|
msgstr "העתק env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "העתק מ-"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "העתק מארח"
|
msgstr "העתק מארח"
|
||||||
@@ -476,13 +490,13 @@ msgstr "העתק YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "ליבה"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "CPU"
|
msgid "CPU"
|
||||||
msgstr "CPU"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "CPU Cores"
|
msgid "CPU Cores"
|
||||||
@@ -550,7 +564,7 @@ msgstr "יומי"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "ברירת מחדל"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
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/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "שימוש בדיסק"
|
msgstr "שימוש בדיסק"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "שימוש בדיסק של {extraFsName}"
|
msgstr "שימוש בדיסק של {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "תצוגה"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "שיטת HTTP"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "שיטת HTTP: POST, GET, או HEAD (ברירת מחדל: 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
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "פורמט מטען (Payload)"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "ניצול ממוצע לליבה"
|
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
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "אחוז הזמן המוקדש לכל מצב"
|
msgstr "אחוז הזמן המוקדש לכל מצב"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "פורט"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "פורטים"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "תהליך התחיל"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "מפתח ציבורי"
|
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
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "שעות שקט"
|
msgstr "שעות שקט"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "קריאה"
|
msgstr "קריאה"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "טבלה"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "לשוניות"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "פעולה זו לא ניתנת לביטול. פעולה זו תמחק
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "פעולה זו תמחק לצמיתות את כל הרשומות שנבחרו ממסד הנתונים."
|
msgstr "פעולה זו תמחק לצמיתות את כל הרשומות שנבחרו ממסד הנתונים."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "תפוקה של {extraFsName}"
|
msgstr "תפוקה של {extraFsName}"
|
||||||
|
|
||||||
@@ -1626,7 +1665,7 @@ msgstr "החלף ערכת נושא"
|
|||||||
#: src/components/add-system.tsx
|
#: src/components/add-system.tsx
|
||||||
#: src/components/routes/settings/tokens-fingerprints.tsx
|
#: src/components/routes/settings/tokens-fingerprints.tsx
|
||||||
msgid "Token"
|
msgid "Token"
|
||||||
msgstr "Token"
|
msgstr ""
|
||||||
|
|
||||||
#: src/components/command-palette.tsx
|
#: src/components/command-palette.tsx
|
||||||
#: src/components/routes/settings/layout.tsx
|
#: src/components/routes/settings/layout.tsx
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "סך נתונים שהתקבלו עבור כל ממשק"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "סך נתונים שנשלחו עבור כל ממשק"
|
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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "העלאה"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "זמן פעילות"
|
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
|
#: 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"
|
msgid "Users"
|
||||||
msgstr "משתמשים"
|
msgstr "משתמשים"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "ניצולת"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "ערך"
|
msgstr "ערך"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "צפה"
|
msgstr "צפה"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "צפה בעוד"
|
msgstr "צפה בעוד"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "פקודת Windows"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "כתיבה"
|
msgstr "כתיבה"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: hr\n"
|
"Language: hr\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
"PO-Revision-Date: 2026-04-05 18:28\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Croatian\n"
|
"Language-Team: Croatian\n"
|
||||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} dostupno"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "Prosjek pada ispod <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Prosjek premašuje <0>{value}{0}</0>"
|
msgstr "Prosjek premašuje <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Prosječan broj I/O operacija koje čekaju na obradu"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Prosječna potrošnja energije grafičkog procesora"
|
msgstr "Prosječna potrošnja energije grafičkog procesora"
|
||||||
|
|
||||||
|
#: 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 "Prosječno vrijeme od čekanja do završetka po operaciji"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Prosječna iskorištenost procesora u cijelom sustavu"
|
msgstr "Prosječna iskorištenost procesora u cijelom sustavu"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Kopiraj env"
|
msgstr "Kopiraj env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Kopiraj iz"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Kopiraj hosta"
|
msgstr "Kopiraj hosta"
|
||||||
@@ -476,7 +490,7 @@ msgstr "Kopiraj YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Jezgra"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Dnevno"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Zadano"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,19 +613,18 @@ msgstr "Mjerna jedinica za disk"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Iskorištenost Diska"
|
msgstr "Iskorištenost Diska"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Iskorištenost diska od {extraFsName}"
|
msgstr "Iskorištenost diska od {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Prikaz"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP metoda"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP metoda: POST, GET ili HEAD (zadano: POST)"
|
msgstr "HTTP metoda: POST, GET ili HEAD (zadano: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "I/O čekanje"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O vrijeme"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O iskorištenost"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Format korisnog tereta (Payload)"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Prosječna iskorištenost po jezgri"
|
msgstr "Prosječna iskorištenost po jezgri"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Postotak vremena u kojem je disk zauzet I/O operacijama"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Postotak vremena provedenog u svakom stanju"
|
msgstr "Postotak vremena provedenog u svakom stanju"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Portovi"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Proces pokrenut"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Javni Ključ"
|
msgstr "Javni Ključ"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Dubina reda"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Tihi sati"
|
msgstr "Tihi sati"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Pročitaj"
|
msgstr "Pročitaj"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tablica"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Kartice"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Ova radnja ne može se poništiti. Svi trenutni zapisi za {name} bit će
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Ovom radnjom će se trajno izbrisati svi odabrani zapisi iz baze podataka."
|
msgstr "Ovom radnjom će se trajno izbrisati svi odabrani zapisi iz baze podataka."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Protok {extraFsName}"
|
msgstr "Protok {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Ukupni podaci primljeni za svako sučelje"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Ukupni podaci poslani za svako sučelje"
|
msgstr "Ukupni podaci poslani za svako sučelje"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Otpremi"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Vrijeme rada"
|
msgstr "Vrijeme rada"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Iskorišteno"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Korisnici"
|
msgstr "Korisnici"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Iskorištenost"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Vrijednost"
|
msgstr "Vrijednost"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Prikaz"
|
msgstr "Prikaz"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Prikaži više"
|
msgstr "Prikaži više"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Windows naredba"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Piši"
|
msgstr "Piši"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: hu\n"
|
"Language: hu\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Hungarian\n"
|
"Language-Team: Hungarian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} elérhető"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "Az átlag esik <0>{value}{0}</0> alá"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Az átlag meghaladja a <0>{value}{0}</0> értéket"
|
msgstr "Az átlag meghaladja a <0>{value}{0}</0> értéket"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Kiszolgálásra váró I/O műveletek átlagos száma"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "GPU-k átlagos energiafogyasztása"
|
msgstr "GPU-k átlagos energiafogyasztása"
|
||||||
|
|
||||||
|
#: 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 "Műveletenkénti átlagos várakozási és befejezési idő"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Rendszerszintű CPU átlagos kihasználtság"
|
msgstr "Rendszerszintű CPU átlagos kihasználtság"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Környezet másolása"
|
msgstr "Környezet másolása"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Másolás innen:"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Hoszt másolása"
|
msgstr "Hoszt másolása"
|
||||||
@@ -476,7 +490,7 @@ msgstr "YAML másolása"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Mag"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "Napi"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Alapértelmezett"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,19 +613,18 @@ msgstr "Lemez mértékegysége"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Lemezhasználat"
|
msgstr "Lemezhasználat"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Lemezhasználat a {extraFsName}"
|
msgstr "Lemezhasználat a {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Megjelenítés"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP metódus"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP metódus: POST, GET vagy HEAD (alapértelmezett: POST)"
|
msgstr "HTTP metódus: POST, GET vagy HEAD (alapértelmezett: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "I/O várakozás"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O idő"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O kihasználtság"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Payload formátum"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Átlagos kihasználtság magonként"
|
msgstr "Átlagos kihasználtság magonként"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Az az időtartam százalékban, amíg a lemez I/O műveletekkel van elfoglalva"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Az idő százalékos aránya minden állapotban"
|
msgstr "Az idő százalékos aránya minden állapotban"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Portok"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Folyamat elindítva"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Nyilvános kulcs"
|
msgstr "Nyilvános kulcs"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Várakozási sor mélysége"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Csendes órák"
|
msgstr "Csendes órák"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Olvasás"
|
msgstr "Olvasás"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tábla"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Lapok"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Ezt a műveletet nem lehet visszavonni! Véglegesen törli a {name} öss
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Ez véglegesen törli az összes kijelölt bejegyzést az adatbázisból."
|
msgstr "Ez véglegesen törli az összes kijelölt bejegyzést az adatbázisból."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "A {extraFsName} átviteli teljesítménye"
|
msgstr "A {extraFsName} átviteli teljesítménye"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Összes fogadott adat minden interfészenként"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Összes elküldött adat minden interfészenként"
|
msgstr "Összes elküldött adat minden interfészenként"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Feltöltés"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Üzemidő"
|
msgstr "Üzemidő"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Felhasznált"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Felhasználók"
|
msgstr "Felhasználók"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Kihasználtság"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Érték"
|
msgstr "Érték"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Nézet"
|
msgstr "Nézet"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Továbbiak megjelenítése"
|
msgstr "Továbbiak megjelenítése"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Windows parancs"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Írás"
|
msgstr "Írás"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: id\n"
|
"Language: id\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"PO-Revision-Date: 2026-03-27 19:17\n"
|
"PO-Revision-Date: 2026-04-05 18:28\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Indonesian\n"
|
"Language-Team: Indonesian\n"
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} tersedia"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "Rata-rata turun di bawah <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "Rata-rata melebihi <0>{value}{0}</0>"
|
msgstr "Rata-rata melebihi <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Jumlah rata-rata operasi I/O yang menunggu untuk dilayani"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Rata-rata konsumsi daya GPU"
|
msgstr "Rata-rata konsumsi daya GPU"
|
||||||
|
|
||||||
|
#: 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 "Waktu rata-rata antrian hingga selesai per operasi"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Rata-rata utilisasi CPU seluruh sistem"
|
msgstr "Rata-rata utilisasi CPU seluruh sistem"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Salin env"
|
msgstr "Salin env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Salin dari"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Salin host"
|
msgstr "Salin host"
|
||||||
@@ -476,7 +490,7 @@ msgstr "Salin YAML"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "Inti"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -599,19 +613,18 @@ msgstr "Unit disk"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Penggunaan Disk"
|
msgstr "Penggunaan Disk"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Penggunaan disk dari {extraFsName}"
|
msgstr "Penggunaan disk dari {extraFsName}"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "Tampilan"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "Metode HTTP"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "Metode HTTP: POST, GET, atau HEAD (default: POST)"
|
msgstr "Metode HTTP: POST, GET, atau HEAD (default: 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 "Waktu I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "Utilisasi I/O"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -910,7 +938,7 @@ msgstr "Jika anda kehilangan kata sandi untuk akun admin anda, anda dapat merese
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Docker image"
|
msgctxt "Docker image"
|
||||||
msgid "Image"
|
msgid "Image"
|
||||||
msgstr ""
|
msgstr "Gambar"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Inactive"
|
msgid "Inactive"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Format payload"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Rata-rata utilisasi per-inti"
|
msgstr "Rata-rata utilisasi per-inti"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Persentase waktu disk sibuk dengan I/O"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Persentase waktu yang dihabiskan di setiap status"
|
msgstr "Persentase waktu yang dihabiskan di setiap status"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr ""
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Port"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Proses dimulai"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Kunci Publik"
|
msgstr "Kunci Publik"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Kedalaman Antrian"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Jam Tenang"
|
msgstr "Jam Tenang"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Baca"
|
msgstr "Baca"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tabel"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Tab"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Aksi ini tidak dapat di kembalikan. ini akan menghapus permanen semua re
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Ini akan menghapus secara permanen semua record yang dipilih dari database."
|
msgstr "Ini akan menghapus secara permanen semua record yang dipilih dari database."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Throughput dari {extraFsName}"
|
msgstr "Throughput dari {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Total data yang diterima untuk setiap antarmuka"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Total data yang dikirim untuk setiap antarmuka"
|
msgstr "Total data yang dikirim untuk setiap antarmuka"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Unggah"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Waktu aktif"
|
msgstr "Waktu aktif"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Digunakan"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Pengguna"
|
msgstr "Pengguna"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Utilisasi"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Nilai"
|
msgstr "Nilai"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Lihat"
|
msgstr "Lihat"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Lihat lebih banyak"
|
msgstr "Lihat lebih banyak"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Perintah Windows"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Tulis"
|
msgstr "Tulis"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: it\n"
|
"Language: it\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Italian\n"
|
"Language-Team: Italian\n"
|
||||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} disponibile"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "La media scende sotto <0>{value}{0}</0>"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "La media supera <0>{value}{0}</0>"
|
msgstr "La media supera <0>{value}{0}</0>"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgid "Average number of I/O operations waiting to be serviced"
|
||||||
|
msgstr "Numero medio di operazioni di I/O in attesa di essere servite"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "Consumo energetico medio delle GPU"
|
msgstr "Consumo energetico medio delle GPU"
|
||||||
|
|
||||||
|
#: 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 "Tempo medio dalla coda al completamento per operazione"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "Utilizzo medio della CPU a livello di sistema"
|
msgstr "Utilizzo medio della CPU a livello di sistema"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "Copia env"
|
msgstr "Copia env"
|
||||||
|
|
||||||
|
#: src/components/alerts/alerts-sheet.tsx
|
||||||
|
msgctxt "Copy alerts from another system"
|
||||||
|
msgid "Copy from"
|
||||||
|
msgstr "Copia da"
|
||||||
|
|
||||||
#: src/components/systems-table/systems-table-columns.tsx
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "Copia host"
|
msgstr "Copia host"
|
||||||
@@ -550,7 +564,7 @@ msgstr "Giornaliero"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "Predefinito"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
msgid "Default time period"
|
||||||
@@ -599,12 +613,11 @@ msgstr "Unità disco"
|
|||||||
|
|
||||||
#: src/components/routes/system/charts/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: 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
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "Utilizzo Disco"
|
msgstr "Utilizzo Disco"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "Utilizzo del disco di {extraFsName}"
|
msgstr "Utilizzo del disco di {extraFsName}"
|
||||||
|
|
||||||
@@ -898,6 +911,21 @@ msgstr "Metodo HTTP"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "Metodo HTTP: POST, GET o HEAD (predefinito: POST)"
|
msgstr "Metodo HTTP: POST, GET o HEAD (predefinito: POST)"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average operation time (iostat await)"
|
||||||
|
msgid "I/O Await"
|
||||||
|
msgstr "Attesa I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "Tempo I/O"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "Utilizzo I/O"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "Formato del payload"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "Utilizzo medio per core"
|
msgstr "Utilizzo medio per core"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "Percentuale di tempo in cui il disco è occupato con l'I/O"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "Percentuale di tempo trascorso in ogni stato"
|
msgstr "Percentuale di tempo trascorso in ogni stato"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "Porta"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "Porte"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "Processo avviato"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "Chiave Pub"
|
msgstr "Chiave Pub"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O average queue depth"
|
||||||
|
msgid "Queue Depth"
|
||||||
|
msgstr "Profondità coda"
|
||||||
|
|
||||||
#: src/components/routes/settings/quiet-hours.tsx
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "Ore silenziose"
|
msgstr "Ore silenziose"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "Lettura"
|
msgstr "Lettura"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "Tabella"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "Schede"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "Questa azione non può essere annullata. Questo eliminerà permanentemen
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "Questo eliminerà permanentemente tutti i record selezionati dal database."
|
msgstr "Questo eliminerà permanentemente tutti i record selezionati dal database."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "Throughput di {extraFsName}"
|
msgstr "Throughput di {extraFsName}"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "Dati totali ricevuti per ogni interfaccia"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "Dati totali inviati per ogni interfaccia"
|
msgstr "Dati totali inviati per ogni interfaccia"
|
||||||
|
|
||||||
|
#: 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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "Carica"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "Tempo di attività"
|
msgstr "Tempo di attività"
|
||||||
|
|
||||||
#: 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
|
#: 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 "Utilizzato"
|
|||||||
msgid "Users"
|
msgid "Users"
|
||||||
msgstr "Utenti"
|
msgstr "Utenti"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "Utilizzo"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Valore"
|
msgstr "Valore"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "Vista"
|
msgstr "Vista"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "Visualizza altro"
|
msgstr "Visualizza altro"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Comando Windows"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "Scrittura"
|
msgstr "Scrittura"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: ja\n"
|
"Language: ja\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Japanese\n"
|
"Language-Team: Japanese\n"
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} が利用可能"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "平均が<0>{value}{0}</0>を下回っています"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "平均が<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 "サービスを待機している I/O 操作の平均数"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "GPUの平均消費電力"
|
msgstr "GPUの平均消費電力"
|
||||||
|
|
||||||
|
#: 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
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "システム全体の平均CPU使用率"
|
msgstr "システム全体の平均CPU使用率"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "環境変数をコピー"
|
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
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "ホストをコピー"
|
msgstr "ホストをコピー"
|
||||||
@@ -476,7 +490,7 @@ msgstr "YAMLをコピー"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "コア"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "毎日"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "デフォルト"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
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/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "ディスク使用率"
|
msgstr "ディスク使用率"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "{extraFsName}のディスク使用率"
|
msgstr "{extraFsName}のディスク使用率"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "表示"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP メソッド"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP メソッド: POST、GET、または HEAD (デフォルト: 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 "I/O 待機"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O 時間"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O 利用率"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "ペイロード形式"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "コアごとの平均使用率"
|
msgstr "コアごとの平均使用率"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "ディスクが I/O でビジー状態である時間の割合"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "各状態で費やした時間の割合"
|
msgstr "各状態で費やした時間の割合"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "ポート"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "ポート"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "プロセス開始"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "公開鍵"
|
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
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "サイレント時間"
|
msgstr "サイレント時間"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "読み取り"
|
msgstr "読み取り"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "テーブル"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "タブ"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "この操作は元に戻せません。これにより、データベー
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "これにより、選択したすべてのレコードがデータベースから完全に削除されます。"
|
msgstr "これにより、選択したすべてのレコードがデータベースから完全に削除されます。"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "{extraFsName}のスループット"
|
msgstr "{extraFsName}のスループット"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "各インターフェースの総受信データ量"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "各インターフェースの総送信データ量"
|
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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "アップロード"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "稼働時間"
|
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
|
#: 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"
|
msgid "Users"
|
||||||
msgstr "ユーザー"
|
msgstr "ユーザー"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "利用率"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "値"
|
msgstr "値"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "表示"
|
msgstr "表示"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "もっと見る"
|
msgstr "もっと見る"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Windows コマンド"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "書き込み"
|
msgstr "書き込み"
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgstr ""
|
|||||||
"Language: ko\n"
|
"Language: ko\n"
|
||||||
"Project-Id-Version: beszel\n"
|
"Project-Id-Version: beszel\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"Last-Translator: \n"
|
||||||
"Language-Team: Korean\n"
|
"Language-Team: Korean\n"
|
||||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||||
@@ -22,7 +22,7 @@ msgstr ""
|
|||||||
#: src/components/footer-repo-link.tsx
|
#: src/components/footer-repo-link.tsx
|
||||||
msgctxt "New version available"
|
msgctxt "New version available"
|
||||||
msgid "{0} available"
|
msgid "{0} available"
|
||||||
msgstr ""
|
msgstr "{0} 사용 가능"
|
||||||
|
|
||||||
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
|
||||||
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
#. placeholder {1}: table.getFilteredRowModel().rows.length
|
||||||
@@ -209,10 +209,19 @@ msgstr "평균이 <0>{value}{0}</0> 아래로 떨어집니다"
|
|||||||
msgid "Average exceeds <0>{value}{0}</0>"
|
msgid "Average exceeds <0>{value}{0}</0>"
|
||||||
msgstr "평균이 <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 "서비스를 기다리는 평균 I/O 작업 수"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/gpu-charts.tsx
|
#: src/components/routes/system/charts/gpu-charts.tsx
|
||||||
msgid "Average power consumption of GPUs"
|
msgid "Average power consumption of GPUs"
|
||||||
msgstr "GPU들의 평균 전원 사용량"
|
msgstr "GPU들의 평균 전원 사용량"
|
||||||
|
|
||||||
|
#: 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
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Average system-wide CPU utilization"
|
msgid "Average system-wide CPU utilization"
|
||||||
msgstr "시스템 전체의 평균 CPU 사용량"
|
msgstr "시스템 전체의 평균 CPU 사용량"
|
||||||
@@ -444,6 +453,11 @@ msgctxt "Environment variables"
|
|||||||
msgid "Copy env"
|
msgid "Copy env"
|
||||||
msgstr "환경 복사"
|
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
|
#: src/components/systems-table/systems-table-columns.tsx
|
||||||
msgid "Copy host"
|
msgid "Copy host"
|
||||||
msgstr "호스트 복사"
|
msgstr "호스트 복사"
|
||||||
@@ -476,7 +490,7 @@ msgstr "YAML 복사"
|
|||||||
#: src/components/routes/system.tsx
|
#: src/components/routes/system.tsx
|
||||||
msgctxt "Core system metrics"
|
msgctxt "Core system metrics"
|
||||||
msgid "Core"
|
msgid "Core"
|
||||||
msgstr ""
|
msgstr "코어"
|
||||||
|
|
||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
#: src/components/systemd-table/systemd-table-columns.tsx
|
#: src/components/systemd-table/systemd-table-columns.tsx
|
||||||
@@ -550,7 +564,7 @@ msgstr "매일"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Default system layout option"
|
msgctxt "Default system layout option"
|
||||||
msgid "Default"
|
msgid "Default"
|
||||||
msgstr ""
|
msgstr "기본값"
|
||||||
|
|
||||||
#: src/components/routes/settings/general.tsx
|
#: src/components/routes/settings/general.tsx
|
||||||
msgid "Default time period"
|
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/disk-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
|
||||||
#: src/lib/alerts.ts
|
#: src/lib/alerts.ts
|
||||||
msgid "Disk Usage"
|
msgid "Disk Usage"
|
||||||
msgstr "디스크 사용량"
|
msgstr "디스크 사용량"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Disk usage of {extraFsName}"
|
msgid "Disk usage of {extraFsName}"
|
||||||
msgstr "{extraFsName}의 디스크 사용량"
|
msgstr "{extraFsName}의 디스크 사용량"
|
||||||
|
|
||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Layout display options"
|
msgctxt "Layout display options"
|
||||||
msgid "Display"
|
msgid "Display"
|
||||||
msgstr ""
|
msgstr "표시"
|
||||||
|
|
||||||
#: src/components/routes/system/charts/cpu-charts.tsx
|
#: src/components/routes/system/charts/cpu-charts.tsx
|
||||||
msgid "Docker CPU Usage"
|
msgid "Docker CPU Usage"
|
||||||
@@ -898,6 +911,21 @@ msgstr "HTTP 메서드"
|
|||||||
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
|
||||||
msgstr "HTTP 메서드: POST, GET 또는 HEAD (기본값: 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 "I/O 대기"
|
||||||
|
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
|
msgctxt "Disk I/O total time spent on read/write"
|
||||||
|
msgid "I/O Time"
|
||||||
|
msgstr "I/O 시간"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Percent of time the disk is busy with I/O"
|
||||||
|
msgid "I/O Utilization"
|
||||||
|
msgstr "I/O 사용률"
|
||||||
|
|
||||||
#. Context: Battery state
|
#. Context: Battery state
|
||||||
#: src/lib/i18n.ts
|
#: src/lib/i18n.ts
|
||||||
msgid "Idle"
|
msgid "Idle"
|
||||||
@@ -1207,6 +1235,10 @@ msgstr "페이로드 형식"
|
|||||||
msgid "Per-core average utilization"
|
msgid "Per-core average utilization"
|
||||||
msgstr "코어별 평균 사용률"
|
msgstr "코어별 평균 사용률"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgid "Percent of time the disk is busy with I/O"
|
||||||
|
msgstr "디스크가 I/O로 바쁜 시간의 비율"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
msgid "Percentage of time spent in each state"
|
msgid "Percentage of time spent in each state"
|
||||||
msgstr "각 상태에서 보낸 시간의 백분율"
|
msgstr "각 상태에서 보낸 시간의 백분율"
|
||||||
@@ -1259,7 +1291,7 @@ msgstr "포트"
|
|||||||
#: src/components/containers-table/containers-table-columns.tsx
|
#: src/components/containers-table/containers-table-columns.tsx
|
||||||
msgctxt "Container ports"
|
msgctxt "Container ports"
|
||||||
msgid "Ports"
|
msgid "Ports"
|
||||||
msgstr ""
|
msgstr "포트"
|
||||||
|
|
||||||
#. Power On Time
|
#. Power On Time
|
||||||
#: src/components/routes/system/smart-table.tsx
|
#: src/components/routes/system/smart-table.tsx
|
||||||
@@ -1284,13 +1316,20 @@ msgstr "프로세스 시작됨"
|
|||||||
msgid "Public Key"
|
msgid "Public Key"
|
||||||
msgstr "공개 키"
|
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
|
#: src/components/routes/settings/quiet-hours.tsx
|
||||||
msgid "Quiet Hours"
|
msgid "Quiet Hours"
|
||||||
msgstr "조용한 시간"
|
msgstr "조용한 시간"
|
||||||
|
|
||||||
#. Disk read
|
#. Disk read
|
||||||
#: 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/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"
|
msgid "Read"
|
||||||
msgstr "읽기"
|
msgstr "읽기"
|
||||||
|
|
||||||
@@ -1549,7 +1588,7 @@ msgstr "표"
|
|||||||
#: src/components/routes/system/info-bar.tsx
|
#: src/components/routes/system/info-bar.tsx
|
||||||
msgctxt "Tabs system layout option"
|
msgctxt "Tabs system layout option"
|
||||||
msgid "Tabs"
|
msgid "Tabs"
|
||||||
msgstr ""
|
msgstr "탭"
|
||||||
|
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Tasks"
|
msgid "Tasks"
|
||||||
@@ -1602,7 +1641,7 @@ msgstr "이 작업은 되돌릴 수 없습니다. 데이터베이스에서 {name
|
|||||||
msgid "This will permanently delete all selected records from the database."
|
msgid "This will permanently delete all selected records from the database."
|
||||||
msgstr "선택한 모든 레코드를 데이터베이스에서 영구적으로 삭제합니다."
|
msgstr "선택한 모든 레코드를 데이터베이스에서 영구적으로 삭제합니다."
|
||||||
|
|
||||||
#: src/components/routes/system/charts/extra-fs-charts.tsx
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
msgid "Throughput of {extraFsName}"
|
msgid "Throughput of {extraFsName}"
|
||||||
msgstr "{extraFsName}의 처리량"
|
msgstr "{extraFsName}의 처리량"
|
||||||
|
|
||||||
@@ -1655,6 +1694,11 @@ msgstr "각 인터페이스별 총합 다운로드 데이터량"
|
|||||||
msgid "Total data sent for each interface"
|
msgid "Total data sent for each interface"
|
||||||
msgstr "각 인터페이스별 총합 업로드 데이터량"
|
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
|
#. placeholder {0}: data.length
|
||||||
#: src/components/systemd-table/systemd-table.tsx
|
#: src/components/systemd-table/systemd-table.tsx
|
||||||
msgid "Total: {0}"
|
msgid "Total: {0}"
|
||||||
@@ -1775,7 +1819,7 @@ msgstr "업로드"
|
|||||||
msgid "Uptime"
|
msgid "Uptime"
|
||||||
msgstr "가동 시간"
|
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
|
#: 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"
|
msgid "Users"
|
||||||
msgstr "사용자"
|
msgstr "사용자"
|
||||||
|
|
||||||
|
#: src/components/routes/system/charts/disk-charts.tsx
|
||||||
|
msgctxt "Disk I/O utilization"
|
||||||
|
msgid "Utilization"
|
||||||
|
msgstr "사용률"
|
||||||
|
|
||||||
#: src/components/alerts-history-columns.tsx
|
#: src/components/alerts-history-columns.tsx
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "값"
|
msgstr "값"
|
||||||
@@ -1806,6 +1855,7 @@ msgid "View"
|
|||||||
msgstr "보기"
|
msgstr "보기"
|
||||||
|
|
||||||
#: src/components/routes/system/cpu-sheet.tsx
|
#: src/components/routes/system/cpu-sheet.tsx
|
||||||
|
#: src/components/routes/system/disk-io-sheet.tsx
|
||||||
#: src/components/routes/system/network-sheet.tsx
|
#: src/components/routes/system/network-sheet.tsx
|
||||||
msgid "View more"
|
msgid "View more"
|
||||||
msgstr "더 보기"
|
msgstr "더 보기"
|
||||||
@@ -1858,7 +1908,9 @@ msgstr "Windows 명령어"
|
|||||||
|
|
||||||
#. Disk write
|
#. Disk write
|
||||||
#: 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/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"
|
msgid "Write"
|
||||||
msgstr "쓰기"
|
msgstr "쓰기"
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user