Compare commits

..

53 Commits

Author SHA1 Message Date
henrygd
6e3fd90834 0.18.7 release 2026-04-05 16:14:39 -04:00
henrygd
5ab82183fa comment out unneeded test that returns different status in dev / prod (add todo) 2026-04-05 16:14:28 -04:00
henrygd
a68e02ca84 update translations 2026-04-05 15:22:26 -04:00
henrygd
0f2e16c63c deps: upgrade pocketbase 2026-04-04 18:31:33 -04:00
Sven van Ginkel
c4009f2b43 feat: add more disk I/O metrics (#1866)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-04 18:28:05 -04:00
Sven van Ginkel
ef0c1420d1 fix(deps): resolve 9 npm Dependabot security alerts (#1882) 2026-04-03 13:23:23 -04:00
henrygd
eb9a8e1ef9 install: update opnsense detection path (#1880) 2026-04-03 12:16:27 -04:00
henrygd
6b5e6ffa9a agent: small refactoring and tests for battery package (#1872) 2026-04-02 21:07:14 -04:00
henrygd
d656036d3b agent: refactor new battery package (#1872) 2026-04-02 21:07:14 -04:00
svenvg93
80b73c7faf feat: implement the battery diectly instead of depency 2026-04-02 21:07:14 -04:00
Sven van Ginkel
afe9eb7a70 feat(hub): copy existing alerts between systems (#1853)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-02 18:04:45 -04:00
Sven van Ginkel
7f565a3086 fix(agent): show correct NVMe capacity for Apple SSDs (#1873)
Co-authored-by: henrygd <hank@henrygd.me>
2026-04-02 15:36:05 -04:00
Sven van Ginkel
77862d4cb1 fix(install): use daemon user on OPNsense to survive reboots#1880 2026-04-02 15:34:50 -04:00
Sven van Ginkel
e158a9001b fix(agent): upgrade gopsutil to v4.26.3 to resolve macOS ARM64 crashes (#1881, #796) 2026-04-02 15:23:08 -04:00
henrygd
f670e868e4 agent: add SENSORS_TIMEOUT env var (#1871) 2026-04-02 15:10:49 -04:00
henrygd
0fff699bf6 hub: return error if accessing /api/beszel/universal-token with a superuser account (#1870) 2026-04-01 22:16:47 -04:00
henrygd
ba10da1b9f hub: add additional validation checks for custom api routes
- Validate the user is assigned to system in authenticated routes where
the user passes in system ID. This protects against a somewhat
impractical scenario where an authenticated user cracks a random 15
character alphanumeric ID of a system that doesn't belong to them via
web API.
- Validate that systemd service exists in database before requesting
service details from agent. This protects against authenticated users
getting unit properties of services that aren't explicitly monitored.
- Refactor responses in authenticated routes to prevent enumeration of
other users' random 15 char system IDs.
2026-04-01 16:30:45 -04:00
henrygd
7f4f14b505 fix(agent,windows): raise timeout on first sensor collection to allow LHM to start 2026-03-31 16:10:59 -04:00
henrygd
2fda4ff264 agent: update LibreHardwareMonitorLib to 0.9.6 2026-03-31 15:55:02 -04:00
henrygd
20b0b40ec8 ui: no centered dialog headings and a few other tweaks 2026-03-31 15:40:52 -04:00
Malith Rukshan
d548a012b4 fix(ui): revert CardTitle to text-2xl to fix tailwind-merge class override (#1860) 2026-03-31 14:55:23 -04:00
henrygd
ce5d1217dd fix(hub): cancel pending down status alert if system paused before alert sent 2026-03-31 14:08:44 -04:00
henrygd
cef09d7cb1 fix(agent): fix windows root disk detection if exe not running on root disk (#1863) 2026-03-31 12:58:42 -04:00
henrygd
f6440acb43 fix(ui): hide noop add system btn and smart actions for readonly users 2026-03-30 19:45:12 -04:00
henrygd
5463a38f0f refactor(hub): move api user role checks to middlewares 2026-03-30 19:35:02 -04:00
Sven van Ginkel
80135fdad3 fix(agent): exclude nested virtual fs when mounting host root to /extra-filesystems in Docker (#1859) 2026-03-30 13:48:54 -04:00
henrygd
5db4eb4346 0.18.6 release 2026-03-29 13:03:48 -04:00
Yi Zhou
f6c5e2928a Add apple-touch-icon link to index.html (#1850) 2026-03-29 12:39:38 -04:00
henrygd
6a207c33fa agent: change disk.Partitions(false) to true - likely fixes empty partition list in docker as of gopsutil 4.26.2 2026-03-29 12:33:45 -04:00
henrygd
9f19afccde hub: reset smart interval on agent reconnect if agent hasn't successfully saved smart devices
this is so people trying to get smart working can see the config changes immediately. not need to wait for the smart interval.
2026-03-29 12:30:39 -04:00
henrygd
f25f2469e3 hub: add debug logs for smart behavior (#1800) 2026-03-28 21:16:26 -04:00
henrygd
5bd43ed461 hub: reset smart interval on agent reconnect if agent hasn't successfully saved smart devices
this is so people trying to get smart working can see the config changes immediately. not need to wait for the smart interval.
2026-03-28 20:47:16 -04:00
henrygd
afdc3f7779 fix(agent): allow GPU_COLLECTOR=nvml without nvidia-smi (#1849) 2026-03-28 18:58:16 -04:00
henrygd
a227c77526 agent: detect podman correctly when using socket proxy (#1846) 2026-03-28 17:43:29 -04:00
henrygd
8202d746af fix(hub): ui bug where charts didn't display 1m max until next update 2026-03-28 12:16:12 -04:00
henrygd
9840b99327 0.18.5 release 2026-03-27 16:27:53 -04:00
henrygd
f7b5a505e8 update translations 2026-03-27 15:57:26 -04:00
henrygd
3cb32ac046 hub(ui): add spacing at bottom of the page if temp tooltip is very long 2026-03-27 14:54:31 -04:00
henrygd
e610d9bfc8 ui: standardize table styles 2026-03-27 14:08:59 -04:00
henrygd
b53fdbe0ef fix(agent): find macmon if /opt/homebrew/bin is not in path (#1746) 2026-03-27 13:52:22 -04:00
henrygd
c7261b56f1 hub(ui): style cleanup and mobile improvements 2026-03-27 12:26:00 -04:00
henrygd
3f4c3d51b6 update go deps 2026-03-27 12:25:17 -04:00
Jim Haff
ad21cab457 Prevent temperature collection from blocking agent stats (#1839) 2026-03-26 20:03:51 -04:00
henrygd
f04684b30a hub(ui): small js optimizations 2026-03-26 19:16:39 -04:00
Stavros
4d4e4fba9b feat: use dropdown menu as navigation on mobile devices (#1840)
Co-authored-by: henrygd <hank@henrygd.me>
2026-03-26 18:27:42 -04:00
henrygd
62587919f4 hub(ui): tabs display for system + major frontend/charts refactoring
- System page tabs display option
- Remove very specific chart components (disk usage, container cpu, etc)
and refactor to use more flexible area and line chart components
- Optimizations around chart handling to decrease mem usage. Charts are
only redrawn now if in view.
- Resolve most of the react dev warnings

Co-authored-by: sveng93 <svenvanginkel@icloud.com>
2026-03-26 15:21:39 -04:00
henrygd
35528332fd hub: fix possible nil pointer panic in realtime worker 2026-03-26 10:48:37 -04:00
henrygd
e3e453140e fix(agent): isolate container network rate tracking per cache interval
Previously, the agent shared a single PrevReadTime timestamp across all
collection intervals (e.g., 1s and 60s). This caused the 60s collector
to divide its accumulated 60s byte delta by the tiny time elapsed since
the last 1s collection, resulting in astronomically inflated network
rates. The fix introduces per-cache-time read time tracking, ensuring
calculations for each interval use their own independent timing context.
2026-03-24 13:07:56 -04:00
henrygd
7a64da9f65 hub: add guard to WSConn.Ping to ensure no nil conn ptr 2026-03-23 15:25:43 -04:00
henrygd
8e71c8ad97 hub: don't retry update check within cache time if request fails 2026-03-22 18:18:31 -04:00
henrygd
97f3b8c61f test(hub): add tests for update endpoint 2026-03-22 17:56:27 -04:00
henrygd
0b0b5d16d7 refactor(hub): move api related code from hub.go to api.go 2026-03-22 17:31:06 -04:00
Sven van Ginkel
b2fd50211e feat(hub): show "update available" notification in hub web UI (#1830)
* refactor, make opt-in, and deprecate /api/beszel/getkey in favor of /api/beszel/info

---------

Co-authored-by: henrygd <hank@henrygd.me>
2026-03-22 17:23:54 -04:00
129 changed files with 16457 additions and 6435 deletions

View File

@@ -19,6 +19,8 @@ import (
gossh "golang.org/x/crypto/ssh"
)
const defaultDataCacheTimeMs uint16 = 60_000
type Agent struct {
sync.Mutex // Used to lock agent while collecting data
debug bool // true if LOG_LEVEL is set to debug
@@ -36,6 +38,7 @@ type Agent struct {
sensorConfig *SensorConfig // Sensors config
systemInfo system.Info // Host system info (dynamic)
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
cache *systemDataCache // Cache for system stats based on cache time
connectionManager *ConnectionManager // Channel to signal connection events
@@ -97,7 +100,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
slog.Debug(beszel.Version)
// initialize docker manager
agent.dockerManager = newDockerManager()
agent.dockerManager = newDockerManager(agent)
// initialize system info
agent.refreshSystemDetails()
@@ -142,7 +145,7 @@ func NewAgent(dataDir ...string) (agent *Agent, err error) {
// if debugging, print stats
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
@@ -164,11 +167,6 @@ func (a *Agent) gatherStats(options common.DataRequestOptions) *system.CombinedD
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)
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
if a.systemdManager != nil && cacheTimeMs == 60_000 {
if a.systemdManager != nil && cacheTimeMs == defaultDataCacheTimeMs {
totalCount := uint16(a.systemdManager.getServiceStatsCount())
if totalCount > 0 {
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)
a.cache.Set(data, cacheTimeMs)
return data
return a.attachSystemDetails(data, cacheTimeMs, options.IncludeDetails)
}
// Start initializes and starts the agent with optional WebSocket connection

View File

@@ -1,84 +1,11 @@
//go:build !freebsd
// Package battery provides functions to check if the system has a battery and to get the battery stats.
// Package battery provides functions to check if the system has a battery and return the charge state and percentage.
package battery
import (
"errors"
"log/slog"
"math"
"github.com/distatus/battery"
const (
stateUnknown uint8 = iota
stateEmpty
stateFull
stateCharging
stateDischarging
stateIdle
)
var (
systemHasBattery = false
haveCheckedBattery = false
)
// HasReadableBattery checks if the system has a battery and returns true if it does.
func HasReadableBattery() bool {
if haveCheckedBattery {
return systemHasBattery
}
haveCheckedBattery = true
batteries, err := battery.GetAll()
for _, bat := range batteries {
if bat != nil && (bat.Full > 0 || bat.Design > 0) {
systemHasBattery = true
break
}
}
if !systemHasBattery {
slog.Debug("No battery found", "err", err)
}
return systemHasBattery
}
// GetBatteryStats returns the current battery percent and charge state
// percent = (current charge of all batteries) / (sum of designed/full capacity of all batteries)
func GetBatteryStats() (batteryPercent uint8, batteryState uint8, err error) {
if !HasReadableBattery() {
return batteryPercent, batteryState, errors.ErrUnsupported
}
batteries, err := battery.GetAll()
// we'll handle errors later by skipping batteries with errors, rather
// than skipping everything because of the presence of some errors.
if len(batteries) == 0 {
return batteryPercent, batteryState, errors.New("no batteries")
}
totalCapacity := float64(0)
totalCharge := float64(0)
errs, partialErrs := err.(battery.Errors)
batteryState = math.MaxUint8
for i, bat := range batteries {
if partialErrs && errs[i] != nil {
// if there were some errors, like missing data, skip it
continue
}
if bat == nil || bat.Full == 0 {
// skip batteries with no capacity. Charge is unlikely to ever be zero, but
// we can't guarantee that, so don't skip based on charge.
continue
}
totalCapacity += bat.Full
totalCharge += min(bat.Current, bat.Full)
if bat.State.Raw >= 0 {
batteryState = uint8(bat.State.Raw)
}
}
if totalCapacity == 0 || batteryState == math.MaxUint8 {
// for macs there's sometimes a ghost battery with 0 capacity
// https://github.com/distatus/battery/issues/34
// Instead of skipping over those batteries, we'll check for total 0 capacity
// and return an error. This also prevents a divide by zero.
return batteryPercent, batteryState, errors.New("no battery capacity")
}
batteryPercent = uint8(totalCharge / totalCapacity * 100)
return batteryPercent, batteryState, nil
}

View 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
}

View File

@@ -0,0 +1,117 @@
//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 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
}

View 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())
}

View File

@@ -1,4 +1,4 @@
//go:build freebsd
//go:build !darwin && !linux && !windows
package battery

View 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
}

View File

@@ -1,6 +1,7 @@
package agent
import (
"context"
"log/slog"
"os"
"path/filepath"
@@ -33,6 +34,34 @@ type diskDiscovery struct {
ctx fsRegistrationContext
}
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
readTime uint64 // cumulative ms spent on reads (from ReadTime)
writeTime uint64 // cumulative ms spent on writes (from WriteTime)
ioTime uint64 // cumulative ms spent doing I/O (from IoTime)
weightedIO uint64 // cumulative weighted ms (queue-depth × ms, from WeightedIO)
readCount uint64 // cumulative read operation count
writeCount uint64 // cumulative write operation count
at time.Time
}
// prevDiskFromCounter creates a prevDisk snapshot from a disk.IOCountersStat at time t.
func prevDiskFromCounter(d disk.IOCountersStat, t time.Time) prevDisk {
return prevDisk{
readBytes: d.ReadBytes,
writeBytes: d.WriteBytes,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: t,
}
}
// parseFilesystemEntry parses a filesystem entry in the format "device__customname"
// Returns the device/filesystem part and the custom name part
func parseFilesystemEntry(entry string) (device, customName string) {
@@ -238,9 +267,11 @@ func (d *diskDiscovery) addConfiguredExtraFilesystems(extraFilesystems string) {
// addPartitionExtraFs registers partitions mounted under /extra-filesystems so
// their display names can come from the folder name while their I/O keys still
// prefer the underlying partition device.
// prefer the underlying partition device. Only direct children are matched to
// avoid registering nested virtual mounts (e.g. /proc, /sys) that are returned by
// disk.Partitions(true) when the host root is bind-mounted in /extra-filesystems.
func (d *diskDiscovery) addPartitionExtraFs(p disk.PartitionStat) {
if !strings.HasPrefix(p.Mountpoint, d.ctx.efPath) {
if filepath.Dir(p.Mountpoint) != d.ctx.efPath {
return
}
device, customName := extraFilesystemPartitionInfo(p)
@@ -273,7 +304,7 @@ func (a *Agent) initializeDiskInfo() {
hasRoot := false
isWindows := runtime.GOOS == "windows"
partitions, err := disk.Partitions(false)
partitions, err := disk.PartitionsWithContext(context.Background(), true)
if err != nil {
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]
if !hasPrev {
// Seed from agent-level fsStats if present, else seed from current
prev = prevDisk{readBytes: stats.TotalRead, writeBytes: stats.TotalWrite, at: stats.Time}
prev = prevDisk{
readBytes: stats.TotalRead,
writeBytes: stats.TotalWrite,
readTime: d.ReadTime,
writeTime: d.WriteTime,
ioTime: d.IoTime,
weightedIO: d.WeightedIO,
readCount: d.ReadCount,
writeCount: d.WriteCount,
at: stats.Time,
}
if prev.at.IsZero() {
prev = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
prev = prevDiskFromCounter(d, now)
}
}
msElapsed := uint64(now.Sub(prev.at).Milliseconds())
// Update per-interval snapshot
a.diskPrev[cacheTimeMs][name] = prevDiskFromCounter(d, now)
// Avoid division by zero or clock issues
if msElapsed < 100 {
// Avoid division by zero or clock issues; update snapshot and continue
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
continue
}
@@ -599,15 +643,38 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
// validate values
if readMbPerSecond > 50_000 || writeMbPerSecond > 50_000 {
slog.Warn("Invalid disk I/O. Resetting.", "name", d.Name, "read", readMbPerSecond, "write", writeMbPerSecond)
// Reset interval snapshot and seed from current
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// also refresh agent baseline to avoid future negatives
a.initializeDiskIoStats(ioCounters)
continue
}
// Update per-interval snapshot
a.diskPrev[cacheTimeMs][name] = prevDisk{readBytes: d.ReadBytes, writeBytes: d.WriteBytes, at: now}
// These properties are calculated differently on different platforms,
// but generally represent cumulative time spent doing reads/writes on the device.
// This can surpass 100% if there are multiple concurrent I/O operations.
// Linux kernel docs:
// This is the total number of milliseconds spent by all reads (as
// measured from __make_request() to end_that_request_last()).
// https://www.kernel.org/doc/Documentation/iostats.txt (fields 4, 8)
diskReadTime := utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(msElapsed) * 100)
diskWriteTime := utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(msElapsed) * 100)
// I/O utilization %: fraction of wall time the device had any I/O in progress (0-100).
diskIoUtilPct := utils.TwoDecimals(float64(d.IoTime-prev.ioTime) / float64(msElapsed) * 100)
// Weighted I/O: queue-depth weighted I/O time, normalized to interval (can exceed 100%).
// Linux kernel field 11: incremented by iops_in_progress × ms_since_last_update.
// Used to display queue depth. Multipled by 100 to increase accuracy of digit truncation (divided by 100 in UI).
diskWeightedIO := utils.TwoDecimals(float64(d.WeightedIO-prev.weightedIO) / float64(msElapsed) * 100)
// r_await / w_await: average time per read/write operation in milliseconds.
// Equivalent to r_await and w_await in iostat.
var rAwait, wAwait float64
if deltaReadCount := d.ReadCount - prev.readCount; deltaReadCount > 0 {
rAwait = utils.TwoDecimals(float64(d.ReadTime-prev.readTime) / float64(deltaReadCount))
}
if deltaWriteCount := d.WriteCount - prev.writeCount; deltaWriteCount > 0 {
wAwait = utils.TwoDecimals(float64(d.WriteTime-prev.writeTime) / float64(deltaWriteCount))
}
// Update global fsStats baseline for cross-interval correctness
stats.Time = now
@@ -617,20 +684,40 @@ func (a *Agent) updateDiskIo(cacheTimeMs uint16, systemStats *system.Stats) {
stats.DiskWritePs = writeMbPerSecond
stats.DiskReadBytes = diskIORead
stats.DiskWriteBytes = diskIOWrite
stats.DiskIoStats[0] = diskReadTime
stats.DiskIoStats[1] = diskWriteTime
stats.DiskIoStats[2] = diskIoUtilPct
stats.DiskIoStats[3] = rAwait
stats.DiskIoStats[4] = wAwait
stats.DiskIoStats[5] = diskWeightedIO
if stats.Root {
systemStats.DiskReadPs = stats.DiskReadPs
systemStats.DiskWritePs = stats.DiskWritePs
systemStats.DiskIO[0] = diskIORead
systemStats.DiskIO[1] = diskIOWrite
systemStats.DiskIoStats[0] = diskReadTime
systemStats.DiskIoStats[1] = diskWriteTime
systemStats.DiskIoStats[2] = diskIoUtilPct
systemStats.DiskIoStats[3] = rAwait
systemStats.DiskIoStats[4] = wAwait
systemStats.DiskIoStats[5] = diskWeightedIO
}
}
}
}
// getRootMountPoint returns the appropriate root mount point for the system
// getRootMountPoint returns the appropriate root mount point for the system.
// On Windows it returns the system drive (e.g. "C:").
// For immutable systems like Fedora Silverblue, it returns /sysroot instead of /
func (a *Agent) getRootMountPoint() string {
if runtime.GOOS == "windows" {
if sd := os.Getenv("SystemDrive"); sd != "" {
return sd
}
return "C:"
}
// 1. Check if /etc/os-release contains indicators of an immutable system
if osReleaseContent, err := os.ReadFile("/etc/os-release"); err == nil {
content := string(osReleaseContent)

View File

@@ -530,6 +530,87 @@ func TestAddExtraFilesystemFolders(t *testing.T) {
})
}
func TestAddPartitionExtraFs(t *testing.T) {
makeDiscovery := func(agent *Agent) diskDiscovery {
return diskDiscovery{
agent: agent,
ctx: fsRegistrationContext{
isWindows: false,
efPath: "/extra-filesystems",
diskIoCounters: map[string]disk.IOCountersStat{
"nvme0n1p1": {Name: "nvme0n1p1"},
"nvme1n1": {Name: "nvme1n1"},
},
},
}
}
t.Run("registers direct child of extra-filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1",
Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root",
})
stats, exists := agent.fsStats["nvme0n1p1"]
assert.True(t, exists)
assert.Equal(t, "/extra-filesystems/nvme0n1p1__caddy1-root", stats.Mountpoint)
assert.Equal(t, "caddy1-root", stats.Name)
})
t.Run("skips nested mount under extra-filesystem bind mount", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
// These simulate the virtual mounts that appear when host / is bind-mounted
// with disk.Partitions(all=true) — e.g. /proc, /sys, /dev visible under the mount.
for _, nested := range []string{
"/extra-filesystems/nvme0n1p1__caddy1-root/proc",
"/extra-filesystems/nvme0n1p1__caddy1-root/sys",
"/extra-filesystems/nvme0n1p1__caddy1-root/dev",
"/extra-filesystems/nvme0n1p1__caddy1-root/run",
} {
d.addPartitionExtraFs(disk.PartitionStat{Device: "tmpfs", Mountpoint: nested})
}
assert.Empty(t, agent.fsStats)
})
t.Run("registers both direct children, skips their nested mounts", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
partitions := []disk.PartitionStat{
{Device: "/dev/nvme0n1p1", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root"},
{Device: "/dev/nvme1n1", Mountpoint: "/extra-filesystems/nvme1n1__caddy1-docker"},
{Device: "proc", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/proc"},
{Device: "sysfs", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/sys"},
{Device: "overlay", Mountpoint: "/extra-filesystems/nvme0n1p1__caddy1-root/var/lib/docker"},
}
for _, p := range partitions {
d.addPartitionExtraFs(p)
}
assert.Len(t, agent.fsStats, 2)
assert.Equal(t, "caddy1-root", agent.fsStats["nvme0n1p1"].Name)
assert.Equal(t, "caddy1-docker", agent.fsStats["nvme1n1"].Name)
})
t.Run("skips partition not under extra-filesystems", func(t *testing.T) {
agent := &Agent{fsStats: make(map[string]*system.FsStats)}
d := makeDiscovery(agent)
d.addPartitionExtraFs(disk.PartitionStat{
Device: "/dev/nvme0n1p1",
Mountpoint: "/",
})
assert.Empty(t, agent.fsStats)
})
}
func TestFindIoDevice(t *testing.T) {
t.Run("matches by device name", func(t *testing.T) {
ioCounters := map[string]disk.IOCountersStat{

View File

@@ -25,6 +25,7 @@ import (
"github.com/henrygd/beszel/agent/deltatracker"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/container"
"github.com/henrygd/beszel/internal/entities/system"
"github.com/blang/semver"
)
@@ -52,20 +53,22 @@ const (
)
type dockerManager struct {
client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
apiContainerList []*container.ApiInfo // List of containers from Docker API
containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
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
agent *Agent // Used to propagate system detail changes back to the agent
client *http.Client // Client to query Docker API
wg sync.WaitGroup // WaitGroup to wait for all goroutines to finish
sem chan struct{} // Semaphore to limit concurrent container requests
containerStatsMutex sync.RWMutex // Mutex to prevent concurrent access to containerStatsMap
apiContainerList []*container.ApiInfo // List of containers from Docker API
containerStatsMap map[string]*container.Stats // Keeps track of container stats
validIds map[string]struct{} // Map of valid container ids, used to prune invalid containers from containerStatsMap
goodDockerVersion bool // Whether docker version is at least 25.0.0 (one-shot works correctly)
dockerVersionChecked bool // Whether a version probe has completed successfully
isWindows bool // Whether the Docker Engine API is running on Windows
buf *bytes.Buffer // Buffer to store and read response bodies
decoder *json.Decoder // Reusable JSON decoder that reads from buf
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)
// Maps cache time intervals to container-specific CPU usage tracking
@@ -77,7 +80,7 @@ type dockerManager struct {
// cacheTimeMs -> DeltaTracker for network bytes sent/received
networkSentTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
networkRecvTrackers map[uint16]*deltatracker.DeltaTracker[string, uint64]
retrySleep func(time.Duration)
lastNetworkReadTime map[uint16]map[string]time.Time // cacheTimeMs -> containerId -> last network read time
}
// userAgentRoundTripper is a custom http.RoundTripper that adds a User-Agent header to all requests
@@ -86,6 +89,14 @@ type userAgentRoundTripper struct {
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
func (u *userAgentRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", u.userAgent)
@@ -133,7 +144,14 @@ func (dm *dockerManager) getDockerStats(cacheTimeMs uint16) ([]*container.Stats,
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)
@@ -285,7 +303,7 @@ func (dm *dockerManager) cycleNetworkDeltasForCacheTime(cacheTimeMs uint16) {
}
// calculateNetworkStats calculates network sent/receive deltas using DeltaTracker
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, stats *container.Stats, initialized bool, name string, cacheTimeMs uint16) (uint64, uint64) {
func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats *container.ApiStats, name string, cacheTimeMs uint16) (uint64, uint64) {
var total_sent, total_recv uint64
for _, v := range apiStats.Networks {
total_sent += v.TxBytes
@@ -304,10 +322,11 @@ func (dm *dockerManager) calculateNetworkStats(ctr *container.ApiInfo, apiStats
sent_delta_raw := sentTracker.Delta(ctr.IdShort)
recv_delta_raw := recvTracker.Delta(ctr.IdShort)
// Calculate bytes per second independently for Tx and Rx if we have previous data
// Calculate bytes per second using per-cache-time read time to avoid
// interference between different cache intervals (e.g. 1000ms vs 60000ms)
var sent_delta, recv_delta uint64
if initialized {
millisecondsElapsed := uint64(time.Since(stats.PrevReadTime).Milliseconds())
if prevReadTime, ok := dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort]; ok {
millisecondsElapsed := uint64(time.Since(prevReadTime).Milliseconds())
if millisecondsElapsed > 0 {
if sent_delta_raw > 0 {
sent_delta = sent_delta_raw * 1000 / millisecondsElapsed
@@ -542,7 +561,13 @@ func (dm *dockerManager) updateContainerStats(ctr *container.ApiInfo, cacheTimeM
}
// Calculate network stats using DeltaTracker
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, stats, initialized, name, cacheTimeMs)
sent_delta, recv_delta := dm.calculateNetworkStats(ctr, res, name, cacheTimeMs)
// Store per-cache-time network read time for next rate calculation
if dm.lastNetworkReadTime[cacheTimeMs] == nil {
dm.lastNetworkReadTime[cacheTimeMs] = make(map[string]time.Time)
}
dm.lastNetworkReadTime[cacheTimeMs][ctr.IdShort] = time.Now()
// Store current network values for legacy compatibility
var total_sent, total_recv uint64
@@ -574,10 +599,13 @@ func (dm *dockerManager) deleteContainerStatsSync(id string) {
for ct := range dm.lastCpuReadTime {
delete(dm.lastCpuReadTime[ct], id)
}
for ct := range dm.lastNetworkReadTime {
delete(dm.lastNetworkReadTime[ct], id)
}
}
// Creates a new http client for Docker or Podman API
func newDockerManager() *dockerManager {
func newDockerManager(agent *Agent) *dockerManager {
dockerHost, exists := utils.GetEnv("DOCKER_HOST")
if exists {
// return nil if set to empty string
@@ -643,6 +671,7 @@ func newDockerManager() *dockerManager {
}
manager := &dockerManager{
agent: agent,
client: &http.Client{
Timeout: timeout,
Transport: userAgentTransport,
@@ -659,51 +688,55 @@ func newDockerManager() *dockerManager {
lastCpuReadTime: make(map[uint16]map[string]time.Time),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
retrySleep: time.Sleep,
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
}
// If using podman, return client
if strings.Contains(dockerHost, "podman") {
manager.usingPodman = true
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)
// Best-effort startup probe. If the engine is not ready yet, getDockerStats will
// retry after the first successful /containers/json request.
_, _ = manager.checkDockerVersion()
return manager
}
// 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.
func (dm *dockerManager) checkDockerVersion() {
var err error
var resp *http.Response
var versionInfo struct {
Version string `json:"Version"`
func (dm *dockerManager) checkDockerVersion() (bool, error) {
resp, err := dm.client.Get("http://localhost/version")
if err != nil {
return false, err
}
const versionMaxTries = 2
for i := 1; i <= versionMaxTries; i++ {
resp, err = dm.client.Get("http://localhost/version")
if err == nil && resp.StatusCode == http.StatusOK {
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 resp.StatusCode != http.StatusOK {
status := resp.Status
resp.Body.Close()
return false, fmt.Errorf("docker version request failed: %s", status)
}
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
}
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
}
// if version > 24, one-shot works correctly and we can limit concurrent operations
@@ -929,3 +962,46 @@ func (dm *dockerManager) GetHostInfo() (info container.HostInfo, err error) {
func (dm *dockerManager) IsPodman() bool {
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)
}

View File

@@ -408,6 +408,7 @@ func TestCalculateNetworkStats(t *testing.T) {
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
}
cacheTimeMs := uint16(30000)
@@ -423,6 +424,11 @@ func TestCalculateNetworkStats(t *testing.T) {
dm.networkSentTrackers[cacheTimeMs] = sentTracker
dm.networkRecvTrackers[cacheTimeMs] = recvTracker
// Set per-cache-time network read time (1 second ago)
dm.lastNetworkReadTime[cacheTimeMs] = map[string]time.Time{
"container1": time.Now().Add(-time.Second),
}
ctr := &container.ApiInfo{
IdShort: "container1",
}
@@ -433,12 +439,8 @@ func TestCalculateNetworkStats(t *testing.T) {
},
}
stats := &container.Stats{
PrevReadTime: time.Now().Add(-time.Second), // 1 second ago
}
// Test with initialized container
sent, recv := dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
sent, recv := dm.calculateNetworkStats(ctr, apiStats, "test-container", cacheTimeMs)
// Should return calculated byte rates per second
assert.GreaterOrEqual(t, sent, uint64(0))
@@ -446,12 +448,76 @@ func TestCalculateNetworkStats(t *testing.T) {
// Cycle and test one-direction change (Tx only) is reflected independently
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
dm.lastNetworkReadTime[cacheTimeMs]["container1"] = time.Now().Add(-time.Second)
apiStats.Networks["eth0"] = container.NetworkStats{TxBytes: 2500, RxBytes: 1800} // +500 Tx only
sent, recv = dm.calculateNetworkStats(ctr, apiStats, stats, true, "test-container", cacheTimeMs)
sent, recv = dm.calculateNetworkStats(ctr, apiStats, "test-container", cacheTimeMs)
assert.Greater(t, sent, uint64(0))
assert.Equal(t, uint64(0), recv)
}
// TestNetworkStatsCacheTimeIsolation verifies that frequent collections at one cache time
// (e.g. 1000ms) don't cause inflated rates at another cache time (e.g. 60000ms).
// This was a bug where PrevReadTime was shared, so the 60000ms tracker would see a
// large byte delta divided by a tiny elapsed time (set by the 1000ms path).
func TestNetworkStatsCacheTimeIsolation(t *testing.T) {
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
}
ctr := &container.ApiInfo{IdShort: "container1"}
fastCache := uint16(1000)
slowCache := uint16(60000)
// Baseline for both cache times at T=0 with 100 bytes total
baseline := &container.ApiStats{
Networks: map[string]container.NetworkStats{
"eth0": {TxBytes: 100, RxBytes: 100},
},
}
dm.calculateNetworkStats(ctr, baseline, "test", fastCache)
dm.calculateNetworkStats(ctr, baseline, "test", slowCache)
// Record read times and cycle both
now := time.Now()
dm.lastNetworkReadTime[fastCache] = map[string]time.Time{"container1": now}
dm.lastNetworkReadTime[slowCache] = map[string]time.Time{"container1": now}
dm.cycleNetworkDeltasForCacheTime(fastCache)
dm.cycleNetworkDeltasForCacheTime(slowCache)
// Simulate many fast (1000ms) collections over ~5 seconds, each adding 10 bytes
totalBytes := uint64(100)
for i := 0; i < 5; i++ {
totalBytes += 10
stats := &container.ApiStats{
Networks: map[string]container.NetworkStats{
"eth0": {TxBytes: totalBytes, RxBytes: totalBytes},
},
}
// Set fast cache read time to 1 second ago
dm.lastNetworkReadTime[fastCache]["container1"] = time.Now().Add(-time.Second)
sent, _ := dm.calculateNetworkStats(ctr, stats, "test", fastCache)
// Fast cache should see ~10 bytes/sec per interval
assert.LessOrEqual(t, sent, uint64(100), "fast cache rate should be reasonable")
dm.cycleNetworkDeltasForCacheTime(fastCache)
}
// Now do slow cache collection — total delta is 50 bytes over ~5 seconds
// Set slow cache read time to 5 seconds ago (the actual elapsed time)
dm.lastNetworkReadTime[slowCache]["container1"] = time.Now().Add(-5 * time.Second)
finalStats := &container.ApiStats{
Networks: map[string]container.NetworkStats{
"eth0": {TxBytes: totalBytes, RxBytes: totalBytes},
},
}
sent, _ := dm.calculateNetworkStats(ctr, finalStats, "test", slowCache)
// Slow cache rate should be ~10 bytes/sec (50 bytes / 5 seconds), NOT 100x inflated
assert.LessOrEqual(t, sent, uint64(100), "slow cache rate should NOT be inflated by fast cache collections")
assert.GreaterOrEqual(t, sent, uint64(1), "slow cache should still report some traffic")
}
func TestDockerManagerCreation(t *testing.T) {
// Test that dockerManager can be created without panicking
dm := &dockerManager{
@@ -460,6 +526,7 @@ func TestDockerManagerCreation(t *testing.T) {
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),
}
assert.NotNil(t, dm)
@@ -467,63 +534,58 @@ func TestDockerManagerCreation(t *testing.T) {
assert.NotNil(t, dm.lastCpuSystem)
assert.NotNil(t, dm.networkSentTrackers)
assert.NotNil(t, dm.networkRecvTrackers)
assert.NotNil(t, dm.lastNetworkReadTime)
}
func TestCheckDockerVersion(t *testing.T) {
tests := []struct {
name string
responses []struct {
statusCode int
body string
}
expectedGood bool
expectedRequests int
name string
statusCode int
body string
server string
expectSuccess bool
expectedGood bool
expectedPodman bool
expectError bool
expectedRequest string
}{
{
name: "200 with good version on first try",
responses: []struct {
statusCode int
body string
}{
{http.StatusOK, `{"Version":"25.0.1"}`},
},
expectedGood: true,
expectedRequests: 1,
name: "good docker version",
statusCode: http.StatusOK,
body: `{"Version":"25.0.1"}`,
expectSuccess: true,
expectedGood: true,
expectedPodman: false,
expectedRequest: "/version",
},
{
name: "200 with old version on first try",
responses: []struct {
statusCode int
body string
}{
{http.StatusOK, `{"Version":"24.0.7"}`},
},
expectedGood: false,
expectedRequests: 1,
name: "old docker version",
statusCode: http.StatusOK,
body: `{"Version":"24.0.7"}`,
expectSuccess: true,
expectedGood: false,
expectedPodman: false,
expectedRequest: "/version",
},
{
name: "non-200 then 200 with good version",
responses: []struct {
statusCode int
body string
}{
{http.StatusServiceUnavailable, `"not ready"`},
{http.StatusOK, `{"Version":"25.1.0"}`},
},
expectedGood: true,
expectedRequests: 2,
name: "podman from server header",
statusCode: http.StatusOK,
body: `{"Version":"5.5.0"}`,
server: "Libpod/5.5.0",
expectSuccess: true,
expectedGood: true,
expectedPodman: true,
expectedRequest: "/version",
},
{
name: "non-200 on all retries",
responses: []struct {
statusCode int
body string
}{
{http.StatusInternalServerError, `"error"`},
{http.StatusUnauthorized, `"error"`},
},
expectedGood: false,
expectedRequests: 2,
name: "non-200 response",
statusCode: http.StatusServiceUnavailable,
body: `"not ready"`,
expectSuccess: false,
expectedGood: false,
expectedPodman: false,
expectError: true,
expectedRequest: "/version",
},
}
@@ -531,13 +593,13 @@ func TestCheckDockerVersion(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
idx := requestCount
requestCount++
if idx >= len(tt.responses) {
idx = len(tt.responses) - 1
assert.Equal(t, tt.expectedRequest, r.URL.EscapedPath())
if tt.server != "" {
w.Header().Set("Server", tt.server)
}
w.WriteHeader(tt.responses[idx].statusCode)
fmt.Fprint(w, tt.responses[idx].body)
w.WriteHeader(tt.statusCode)
fmt.Fprint(w, tt.body)
}))
defer server.Close()
@@ -549,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.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
dm := &dockerManager{
client: &http.Client{
@@ -570,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.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) {
dm := &dockerManager{
lastCpuContainer: map[uint16]map[string]uint64{
@@ -651,6 +875,7 @@ func TestDockerStatsWithMockData(t *testing.T) {
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),
containerStatsMap: make(map[string]*container.Stats),
}
@@ -796,23 +1021,22 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
dm := &dockerManager{
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
}
ctr := &container.ApiInfo{IdShort: "test-container"}
cacheTimeMs := uint16(30000) // Test with 30 second cache
// Use exact timing for deterministic results
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
stats := &container.Stats{
PrevReadTime: exactly1000msAgo,
}
// First call sets baseline
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs)
// First call sets baseline (no previous read time, so rates should be 0)
sent1, recv1 := dm.calculateNetworkStats(ctr, apiStats1, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent1)
assert.Equal(t, uint64(0), recv1)
// Cycle to establish baseline for this cache time
// Record read time and cycle to establish baseline for this cache time
exactly1000msAgo := time.Now().Add(-1000 * time.Millisecond)
dm.lastNetworkReadTime[cacheTimeMs] = map[string]time.Time{
"test-container": exactly1000msAgo,
}
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
// Calculate expected results precisely
@@ -823,7 +1047,7 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
expectedRecvRate := deltaRecv * 1000 / expectedElapsedMs // Should be exactly 1000000
// Second call with changed data
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
sent2, recv2 := dm.calculateNetworkStats(ctr, apiStats2, "test", cacheTimeMs)
// Should be exactly the expected rates (no tolerance needed)
assert.Equal(t, expectedSentRate, sent2)
@@ -831,12 +1055,13 @@ func TestNetworkStatsCalculationWithRealData(t *testing.T) {
// Bad speed cap: set absurd delta over 1ms and expect 0 due to cap
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
stats.PrevReadTime = time.Now().Add(-1 * time.Millisecond)
dm.lastNetworkReadTime[cacheTimeMs]["test-container"] = time.Now().Add(-1 * time.Millisecond)
apiStats1.Networks["eth0"] = container.NetworkStats{TxBytes: 0, RxBytes: 0}
apiStats2.Networks["eth0"] = container.NetworkStats{TxBytes: 10 * 1024 * 1024 * 1024, RxBytes: 0} // 10GB delta
_, _ = dm.calculateNetworkStats(ctr, apiStats1, stats, true, "test", cacheTimeMs) // baseline
_, _ = dm.calculateNetworkStats(ctr, apiStats1, "test", cacheTimeMs) // baseline
dm.cycleNetworkDeltasForCacheTime(cacheTimeMs)
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, stats, true, "test", cacheTimeMs)
dm.lastNetworkReadTime[cacheTimeMs]["test-container"] = time.Now().Add(-1 * time.Millisecond)
sent3, recv3 := dm.calculateNetworkStats(ctr, apiStats2, "test", cacheTimeMs)
assert.Equal(t, uint64(0), sent3)
assert.Equal(t, uint64(0), recv3)
}
@@ -857,6 +1082,7 @@ func TestContainerStatsEndToEndWithRealData(t *testing.T) {
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),
containerStatsMap: make(map[string]*container.Stats),
}
@@ -978,6 +1204,7 @@ func TestDockerStatsWorkflow(t *testing.T) {
lastCpuSystem: make(map[uint16]map[string]uint64),
networkSentTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
networkRecvTrackers: make(map[uint16]*deltatracker.DeltaTracker[string, uint64]),
lastNetworkReadTime: make(map[uint16]map[string]time.Time),
containerStatsMap: make(map[string]*container.Stats),
}
@@ -1242,6 +1469,7 @@ func TestUpdateContainerStatsUsesPodmanInspectHealthFallback(t *testing.T) {
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),
}
ctr := &container.ApiInfo{

View File

@@ -461,7 +461,7 @@ func (gm *GPUManager) discoverGpuCapabilities() gpuCapabilities {
caps.hasNvtop = true
}
if runtime.GOOS == "darwin" {
if _, err := exec.LookPath(macmonCmd); err == nil {
if _, err := utils.LookPathHomebrew(macmonCmd); err == nil {
caps.hasMacmon = true
}
if _, err := exec.LookPath(powermetricsCmd); err == nil {
@@ -542,7 +542,7 @@ func (gm *GPUManager) collectorDefinitions(caps gpuCapabilities) map[collectorSo
return map[collectorSource]collectorDefinition{
collectorSourceNVML: {
group: collectorGroupNvidia,
available: caps.hasNvidiaSmi,
available: true,
start: func(_ func()) bool {
return gm.startNvmlCollector()
},
@@ -734,9 +734,6 @@ func NewGPUManager() (*GPUManager, error) {
}
var gm GPUManager
caps := gm.discoverGpuCapabilities()
if !hasAnyGpuCollector(caps) {
return nil, fmt.Errorf(noGPUFoundMsg)
}
gm.GpuDataMap = make(map[string]*system.GPUData)
// Jetson devices should always use tegrastats (ignore GPU_COLLECTOR).
@@ -745,7 +742,7 @@ func NewGPUManager() (*GPUManager, error) {
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) != "" {
priorities := parseCollectorPriority(collectorConfig)
if gm.startCollectorsByPriority(priorities, caps) == 0 {
@@ -754,6 +751,10 @@ func NewGPUManager() (*GPUManager, error) {
return &gm, nil
}
if !hasAnyGpuCollector(caps) {
return nil, fmt.Errorf(noGPUFoundMsg)
}
// auto-detect and start collectors when GPU_COLLECTOR is unset.
if gm.startCollectorsByPriority(gm.resolveLegacyCollectorPriority(caps), caps) == 0 {
return nil, fmt.Errorf(noGPUFoundMsg)

View File

@@ -13,6 +13,7 @@ import (
"strings"
"time"
"github.com/henrygd/beszel/agent/utils"
"github.com/henrygd/beszel/internal/entities/system"
)
@@ -171,7 +172,11 @@ type macmonSample struct {
}
func (gm *GPUManager) collectMacmonPipe() (err error) {
cmd := exec.Command(macmonCmd, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
macmonPath, err := utils.LookPathHomebrew(macmonCmd)
if err != nil {
return err
}
cmd := exec.Command(macmonPath, "pipe", "-i", strconv.Itoa(macmonIntervalMs))
// Avoid blocking if macmon writes to stderr.
cmd.Stderr = io.Discard
stdout, err := cmd.StdoutPipe()

View File

@@ -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) {
dir := t.TempDir()
t.Setenv("PATH", dir)

View File

@@ -8,6 +8,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.5" />
<PackageReference Include="LibreHardwareMonitorLib" Version="0.9.6" />
</ItemGroup>
</Project>

View File

@@ -2,12 +2,14 @@ package agent
import (
"context"
"errors"
"fmt"
"log/slog"
"path"
"runtime"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/henrygd/beszel/agent/utils"
@@ -17,13 +19,20 @@ import (
"github.com/shirou/gopsutil/v4/sensors"
)
var errTemperatureFetchTimeout = errors.New("temperature collection timed out")
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
type SensorConfig struct {
context context.Context
sensors map[string]struct{}
primarySensor string
timeout time.Duration
isBlacklist bool
hasWildcards bool
skipCollection bool
firstRun bool
}
func (a *Agent) newSensorConfig() *SensorConfig {
@@ -31,20 +40,29 @@ func (a *Agent) newSensorConfig() *SensorConfig {
sysSensors, _ := utils.GetEnv("SYS_SENSORS")
sensorsEnvVal, sensorsSet := utils.GetEnv("SENSORS")
skipCollection := sensorsSet && sensorsEnvVal == ""
sensorsTimeout, _ := utils.GetEnv("SENSORS_TIMEOUT")
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, skipCollection)
return a.newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout, skipCollection)
}
// Matches sensors.TemperaturesWithContext to allow for panic recovery (gopsutil/issues/1832)
type getTempsFn func(ctx context.Context) ([]sensors.TemperatureStat, error)
// newSensorConfigWithEnv creates a SensorConfig with the provided environment variables
// sensorsSet indicates if the SENSORS environment variable was explicitly set (even to empty string)
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal string, skipCollection bool) *SensorConfig {
func (a *Agent) newSensorConfigWithEnv(primarySensor, sysSensors, sensorsEnvVal, sensorsTimeout string, skipCollection bool) *SensorConfig {
timeout := 2 * time.Second
if sensorsTimeout != "" {
if d, err := time.ParseDuration(sensorsTimeout); err == nil {
timeout = d
} else {
slog.Warn("Invalid SENSORS_TIMEOUT", "value", sensorsTimeout)
}
}
config := &SensorConfig{
context: context.Background(),
primarySensor: primarySensor,
timeout: timeout,
skipCollection: skipCollection,
firstRun: true,
sensors: make(map[string]struct{}),
}
@@ -86,10 +104,12 @@ func (a *Agent) updateTemperatures(systemStats *system.Stats) {
// reset high temp
a.systemInfo.DashboardTemp = 0
temps, err := a.getTempsWithPanicRecovery(getSensorTemps)
temps, err := a.getTempsWithTimeout(getSensorTemps)
if err != nil {
// retry once on panic (gopsutil/issues/1832)
temps, err = a.getTempsWithPanicRecovery(getSensorTemps)
if !errors.Is(err, errTemperatureFetchTimeout) {
temps, err = a.getTempsWithTimeout(getSensorTemps)
}
if err != nil {
slog.Warn("Error updating temperatures", "err", err)
if len(systemStats.Temperatures) > 0 {
@@ -152,6 +172,34 @@ func (a *Agent) getTempsWithPanicRecovery(getTemps getTempsFn) (temps []sensors.
return
}
func (a *Agent) getTempsWithTimeout(getTemps getTempsFn) ([]sensors.TemperatureStat, error) {
type result struct {
temps []sensors.TemperatureStat
err error
}
// Use a longer timeout on the first run to allow for initialization
// (e.g. Windows LHM subprocess startup)
timeout := a.sensorConfig.timeout
if a.sensorConfig.firstRun {
a.sensorConfig.firstRun = false
timeout = 10 * time.Second
}
resultCh := make(chan result, 1)
go func() {
temps, err := a.getTempsWithPanicRecovery(getTemps)
resultCh <- result{temps: temps, err: err}
}()
select {
case res := <-resultCh:
return res.temps, res.err
case <-time.After(timeout):
return nil, errTemperatureFetchTimeout
}
}
// isValidSensor checks if a sensor is valid based on the sensor name and the sensor config
func isValidSensor(sensorName string, config *SensorConfig) bool {
// if no sensors configured, everything is valid

View File

@@ -6,6 +6,7 @@ import (
"context"
"fmt"
"testing"
"time"
"github.com/henrygd/beszel/internal/entities/system"
@@ -167,6 +168,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
primarySensor string
sysSensors string
sensors string
sensorsTimeout string
skipCollection bool
expectedConfig *SensorConfig
}{
@@ -178,12 +180,37 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "",
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
skipCollection: false,
},
},
{
name: "Custom timeout",
primarySensor: "",
sysSensors: "",
sensors: "",
sensorsTimeout: "5s",
expectedConfig: &SensorConfig{
context: context.Background(),
timeout: 5 * time.Second,
sensors: map[string]struct{}{},
},
},
{
name: "Invalid timeout falls back to default",
primarySensor: "",
sysSensors: "",
sensors: "",
sensorsTimeout: "notaduration",
expectedConfig: &SensorConfig{
context: context.Background(),
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
},
},
{
name: "Explicitly set to empty string",
primarySensor: "",
@@ -193,6 +220,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "",
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
@@ -207,6 +235,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{},
isBlacklist: false,
hasWildcards: false,
@@ -220,6 +249,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{
"cpu_temp": {},
"gpu_temp": {},
@@ -236,6 +266,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{
"cpu_temp": {},
"gpu_temp": {},
@@ -252,6 +283,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{
"cpu_*": {},
"gpu_temp": {},
@@ -268,6 +300,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
expectedConfig: &SensorConfig{
context: context.Background(),
primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{
"cpu_*": {},
"gpu_temp": {},
@@ -283,6 +316,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
sensors: "cpu_temp",
expectedConfig: &SensorConfig{
primarySensor: "cpu_temp",
timeout: 2 * time.Second,
sensors: map[string]struct{}{
"cpu_temp": {},
},
@@ -294,7 +328,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.skipCollection)
result := agent.newSensorConfigWithEnv(tt.primarySensor, tt.sysSensors, tt.sensors, tt.sensorsTimeout, tt.skipCollection)
// Check primary sensor
assert.Equal(t, tt.expectedConfig.primarySensor, result.primarySensor)
@@ -313,6 +347,7 @@ func TestNewSensorConfigWithEnv(t *testing.T) {
// Check flags
assert.Equal(t, tt.expectedConfig.isBlacklist, result.isBlacklist)
assert.Equal(t, tt.expectedConfig.hasWildcards, result.hasWildcards)
assert.Equal(t, tt.expectedConfig.timeout, result.timeout)
// Check context
if tt.sysSensors != "" {
@@ -332,12 +367,14 @@ func TestNewSensorConfig(t *testing.T) {
t.Setenv("BESZEL_AGENT_PRIMARY_SENSOR", "test_primary")
t.Setenv("BESZEL_AGENT_SYS_SENSORS", "/test/path")
t.Setenv("BESZEL_AGENT_SENSORS", "test_sensor1,test_*,test_sensor3")
t.Setenv("BESZEL_AGENT_SENSORS_TIMEOUT", "7s")
agent := &Agent{}
result := agent.newSensorConfig()
// Verify results
assert.Equal(t, "test_primary", result.primarySensor)
assert.Equal(t, 7*time.Second, result.timeout)
assert.NotNil(t, result.sensors)
assert.Equal(t, 3, len(result.sensors))
assert.True(t, result.hasWildcards)
@@ -526,3 +563,59 @@ func TestGetTempsWithPanicRecovery(t *testing.T) {
})
}
}
func TestGetTempsWithTimeout(t *testing.T) {
agent := &Agent{
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 10 * time.Millisecond,
},
}
t.Run("returns temperatures before timeout", func(t *testing.T) {
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
})
require.NoError(t, err)
require.Len(t, temps, 1)
assert.Equal(t, "cpu_temp", temps[0].SensorKey)
})
t.Run("returns timeout error when collector hangs", func(t *testing.T) {
temps, err := agent.getTempsWithTimeout(func(ctx context.Context) ([]sensors.TemperatureStat, error) {
time.Sleep(50 * time.Millisecond)
return []sensors.TemperatureStat{{SensorKey: "cpu_temp", Temperature: 42}}, nil
})
assert.Nil(t, temps)
assert.ErrorIs(t, err, errTemperatureFetchTimeout)
})
}
func TestUpdateTemperaturesSkipsOnTimeout(t *testing.T) {
agent := &Agent{
systemInfo: system.Info{DashboardTemp: 99},
sensorConfig: &SensorConfig{
context: context.Background(),
timeout: 10 * time.Millisecond,
},
}
t.Cleanup(func() {
getSensorTemps = sensors.TemperaturesWithContext
})
getSensorTemps = func(ctx context.Context) ([]sensors.TemperatureStat, error) {
time.Sleep(50 * time.Millisecond)
return nil, nil
}
stats := &system.Stats{
Temperatures: map[string]float64{"stale": 50},
}
agent.updateTemperatures(stats)
assert.Equal(t, 0.0, agent.systemInfo.DashboardTemp)
assert.Equal(t, map[string]float64{}, stats.Temperatures)
}

View File

@@ -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
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)
}

View File

@@ -25,12 +25,15 @@ import (
// SmartManager manages data collection for SMART devices
type SmartManager struct {
sync.Mutex
SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo
refreshMutex sync.Mutex
lastScanTime time.Time
smartctlPath string
excludedDevices map[string]struct{}
SmartDataMap map[string]*smart.SmartData
SmartDevices []*DeviceInfo
refreshMutex sync.Mutex
lastScanTime time.Time
smartctlPath string
excludedDevices map[string]struct{}
darwinNvmeOnce sync.Once
darwinNvmeCapacity map[string]uint64 // serial → bytes cache, written once via darwinNvmeOnce
darwinNvmeProvider func() ([]byte, error) // overridable for testing
}
type scanOutput struct {
@@ -1033,6 +1036,52 @@ func parseScsiGigabytesProcessed(value string) int64 {
return parsed
}
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
// via smartctl. Results are cached after the first call via sync.Once.
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
sm.darwinNvmeOnce.Do(func() {
sm.darwinNvmeCapacity = make(map[string]uint64)
provider := sm.darwinNvmeProvider
if provider == nil {
provider = func() ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
}
}
out, err := provider()
if err != nil {
slog.Debug("system_profiler NVMe lookup failed", "err", err)
return
}
var result struct {
SPNVMeDataType []struct {
Items []struct {
DeviceSerial string `json:"device_serial"`
SizeInBytes uint64 `json:"size_in_bytes"`
} `json:"_items"`
} `json:"SPNVMeDataType"`
}
if err := json.Unmarshal(out, &result); err != nil {
slog.Debug("system_profiler NVMe parse failed", "err", err)
return
}
for _, controller := range result.SPNVMeDataType {
for _, item := range controller.Items {
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
}
}
}
})
return sm.darwinNvmeCapacity[serial]
}
// parseSmartForNvme parses the output of smartctl --all -j /dev/nvmeX and updates the SmartDataMap
// Returns hasValidData and exitStatus
func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
@@ -1069,6 +1118,9 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
smartData.SerialNumber = data.SerialNumber
smartData.FirmwareVersion = data.FirmwareVersion
smartData.Capacity = data.UserCapacity.Bytes
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
}
smartData.Temperature = data.NVMeSmartHealthInformationLog.Temperature
smartData.SmartStatus = getSmartStatus(smartData.Temperature, data.SmartStatus.Passed)
smartData.DiskName = data.Device.Name
@@ -1104,32 +1156,21 @@ func (sm *SmartManager) parseSmartForNvme(output []byte) (bool, int) {
// detectSmartctl checks if smartctl is installed, returns an error if not
func (sm *SmartManager) detectSmartctl() (string, error) {
isWindows := runtime.GOOS == "windows"
// Load embedded smartctl.exe for Windows amd64 builds.
if isWindows && runtime.GOARCH == "amd64" {
if path, err := ensureEmbeddedSmartctl(); err == nil {
return path, nil
if runtime.GOOS == "windows" {
// Load embedded smartctl.exe for Windows amd64 builds.
if runtime.GOARCH == "amd64" {
if path, err := ensureEmbeddedSmartctl(); err == nil {
return path, nil
}
}
}
if path, err := exec.LookPath("smartctl"); err == nil {
return path, nil
}
locations := []string{}
if isWindows {
locations = append(locations,
"C:\\Program Files\\smartmontools\\bin\\smartctl.exe",
)
} else {
locations = append(locations, "/opt/homebrew/bin/smartctl")
}
for _, location := range locations {
// Try to find smartctl in the default installation location
const location = "C:\\Program Files\\smartmontools\\bin\\smartctl.exe"
if _, err := os.Stat(location); err == nil {
return location, nil
}
}
return "", errors.New("smartctl not found")
return utils.LookPathHomebrew("smartctl")
}
// isNvmeControllerPath checks if the path matches an NVMe controller pattern

View File

@@ -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)
}

View File

@@ -8,7 +8,6 @@ import (
"os"
"runtime"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/agent/battery"
@@ -23,13 +22,6 @@ import (
"github.com/shirou/gopsutil/v4/mem"
)
// prevDisk stores previous per-device disk counters for a given cache interval
type prevDisk struct {
readBytes uint64
writeBytes uint64
at time.Time
}
// Sets initial / non-changing values about the host system
func (a *Agent) refreshSystemDetails() {
a.systemInfo.AgentVersion = beszel.Version
@@ -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
func (a *Agent) getSystemStats(cacheTimeMs uint16) system.Stats {
var systemStats system.Stats

61
agent/system_test.go Normal file
View 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)
}

View 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
}
}

View File

@@ -4,6 +4,9 @@ import (
"io"
"math"
"os"
"os/exec"
"path/filepath"
"runtime"
"strconv"
"strings"
)
@@ -86,3 +89,24 @@ func ReadUintFile(path string) (uint64, bool) {
}
return parsed, true
}
// LookPathHomebrew is like exec.LookPath but also checks Homebrew paths.
func LookPathHomebrew(file string) (string, error) {
foundPath, lookPathErr := exec.LookPath(file)
if lookPathErr == nil {
return foundPath, nil
}
var homebrewPath string
switch runtime.GOOS {
case "darwin":
homebrewPath = filepath.Join("/opt", "homebrew", "bin", file)
case "linux":
homebrewPath = filepath.Join("/home", "linuxbrew", ".linuxbrew", "bin", file)
}
if homebrewPath != "" {
if _, err := os.Stat(homebrewPath); err == nil {
return homebrewPath, nil
}
}
return "", lookPathErr
}

View File

@@ -6,7 +6,7 @@ import "github.com/blang/semver"
const (
// Version is the current version of the application.
Version = "0.18.4"
Version = "0.18.7"
// AppName is the name of the application.
AppName = "beszel"
)

44
go.mod
View File

@@ -5,24 +5,24 @@ go 1.26.1
require (
github.com/blang/semver v3.5.1+incompatible
github.com/coreos/go-systemd/v22 v22.7.0
github.com/distatus/battery v0.11.0
github.com/ebitengine/purego v0.9.1
github.com/ebitengine/purego v0.10.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gliderlabs/ssh v0.3.8
github.com/google/uuid v1.6.0
github.com/lxzan/gws v1.8.9
github.com/nicholas-fedor/shoutrrr v0.13.2
github.com/lxzan/gws v1.9.1
github.com/nicholas-fedor/shoutrrr v0.14.1
github.com/pocketbase/dbx v1.12.0
github.com/pocketbase/pocketbase v0.36.4
github.com/shirou/gopsutil/v4 v4.26.1
github.com/pocketbase/pocketbase v0.36.8
github.com/shirou/gopsutil/v4 v4.26.3
github.com/spf13/cast v1.10.0
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.48.0
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa
golang.org/x/sys v0.41.0
golang.org/x/crypto v0.49.0
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90
golang.org/x/sys v0.42.0
gopkg.in/yaml.v3 v3.0.1
howett.net/plist v1.0.1
)
require (
@@ -30,10 +30,10 @@ require (
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dolthub/maphash v0.1.0 // indirect
github.com/domodwyer/mailyak/v3 v3.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/eclipse/paho.golang v0.23.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/ganigeorgiev/fexpr v0.5.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -41,9 +41,10 @@ require (
github.com/go-sql-driver/mysql v1.9.1 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
@@ -54,15 +55,14 @@ require (
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/image v0.36.0 // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/term v0.40.0 // indirect
golang.org/x/text v0.34.0 // indirect
howett.net/plist v1.0.1 // indirect
modernc.org/libc v1.67.6 // indirect
golang.org/x/image v0.38.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.41.0 // indirect
golang.org/x/text v0.35.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.45.0 // indirect
modernc.org/sqlite v1.48.0 // indirect
)

114
go.sum
View File

@@ -17,18 +17,16 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distatus/battery v0.11.0 h1:KJk89gz90Iq/wJtbjjM9yUzBXV+ASV/EG2WOOL7N8lc=
github.com/distatus/battery v0.11.0/go.mod h1:KmVkE8A8hpIX4T78QRdMktYpEp35QfOL8A8dwZBxq2k=
github.com/dolthub/maphash v0.1.0 h1:bsQ7JsF4FkkWyrP3oCnFJgrCUAFbFf3kOl4L/QxPDyQ=
github.com/dolthub/maphash v0.1.0/go.mod h1:gkg4Ch4CdCDu5h6PMriVLawB7koZ+5ijb9puGMV50a4=
github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8=
github.com/domodwyer/mailyak/v3 v3.6.2/go.mod h1:lOm/u9CyCVWHeaAmHIdF4RiKVxKUT/H5XX10lIKAL6c=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvSyR6Mk=
github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04=
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
@@ -58,10 +56,12 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc=
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc h1:VBbFa1lDYWEeV5FZKUiYKYT0VxCp9twUmmaq9eb8sXw=
github.com/google/pprof v0.0.0-20260302011040-a15ffb7f9dcc/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -69,24 +69,24 @@ github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLf
github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A=
github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.8.9 h1:VU3SGUeWlQrEwfUSfokcZep8mdg/BrUF+y73YYshdBM=
github.com/lxzan/gws v1.8.9/go.mod h1:d9yHaR1eDTBHagQC6KY7ycUOaz5KWeqQtP3xu7aMK8Y=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lxzan/gws v1.9.1 h1:4lbIp4cW0hOLP3ejFHR/uWRy741AURx7oKkNNi2OT9o=
github.com/lxzan/gws v1.9.1/go.mod h1:gXHSCPmTGryWJ4icuqy8Yho32E4YIMHH0fkDRYJRbdc=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nicholas-fedor/shoutrrr v0.13.2 h1:hfsYBIqSFYGg92pZP5CXk/g7/OJIkLYmiUnRl+AD1IA=
github.com/nicholas-fedor/shoutrrr v0.13.2/go.mod h1:ZqzV3gY/Wj6AvWs1etlO7+yKbh4iptSbeL8avBpMQbA=
github.com/nicholas-fedor/shoutrrr v0.14.1 h1:6sx4cJNfNuUtD6ygGlB0dqcCQ+abfsUh+b+6jgujf6A=
github.com/nicholas-fedor/shoutrrr v0.14.1/go.mod h1:U7IywBkLpBV7rgn8iLbQ9/LklJG1gm24bFv5cXXsDKs=
github.com/onsi/ginkgo/v2 v2.28.1 h1:S4hj+HbZp40fNKuLUQOYLDgZLwNUVn19N3Atb98NCyI=
github.com/onsi/ginkgo/v2 v2.28.1/go.mod h1:CLtbVInNckU3/+gC8LzkGUb9oF+e8W8TdUsxPwvdOgE=
github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28=
@@ -96,8 +96,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pocketbase/dbx v1.12.0 h1:/oLErM+A0b4xI0PWTGPqSDVjzix48PqI/bng2l0PzoA=
github.com/pocketbase/dbx v1.12.0/go.mod h1:xXRCIAKTHMgUCyCKZm55pUOdvFziJjQfXaWKhu2vhMs=
github.com/pocketbase/pocketbase v0.36.4 h1:zTjRZbp2WfTOJJfb+pFRWa200UaQwxZYt8RzkFMlAZ4=
github.com/pocketbase/pocketbase v0.36.4/go.mod h1:9CiezhRudd9FZGa5xZa53QZBTNxc5vvw/FGG+diAECI=
github.com/pocketbase/pocketbase v0.36.8 h1:gCNqoesZ44saYOD3J7edhi5nDwUWKyQG7boM/kVwz2c=
github.com/pocketbase/pocketbase v0.36.8/go.mod h1:OY4WaXbP0WnF/EXoBbboWJK+ZSZ1A85tiA0sjrTKxTA=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
@@ -105,8 +105,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qq
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
@@ -115,6 +115,8 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
@@ -126,44 +128,44 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -175,18 +177,18 @@ howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -195,8 +197,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4=
modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -109,6 +109,18 @@ func (am *AlertManager) cancelPendingAlert(alertID string) bool {
return true
}
// CancelPendingStatusAlerts cancels all pending status alert timers for a given system.
// This is called when a system is paused to prevent delayed alerts from firing.
func (am *AlertManager) CancelPendingStatusAlerts(systemID string) {
am.pendingAlerts.Range(func(key, value any) bool {
info := value.(*alertInfo)
if info.alertData.SystemID == systemID {
am.cancelPendingAlert(key.(string))
}
return true
})
}
// processPendingAlert sends a "down" alert if the pending alert has expired and the system is still down.
func (am *AlertManager) processPendingAlert(alertID string) {
value, loaded := am.pendingAlerts.LoadAndDelete(alertID)

View File

@@ -941,3 +941,68 @@ func TestStatusAlertClearedBeforeSend(t *testing.T) {
assert.EqualValues(t, 0, alertHistoryCount, "Should have no unresolved alert history records since alert never triggered")
})
}
func TestCancelPendingStatusAlertsClearsAllAlertsForSystem(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userSettings, err := hub.FindFirstRecordByFilter("user_settings", "user={:user}", map[string]any{"user": user.Id})
require.NoError(t, err)
userSettings.Set("settings", `{"emails":["test@example.com"],"webhooks":[]}`)
require.NoError(t, hub.Save(userSettings))
systemCollection, err := hub.FindCollectionByNameOrId("systems")
require.NoError(t, err)
system1 := core.NewRecord(systemCollection)
system1.Set("name", "system-1")
system1.Set("status", "up")
system1.Set("host", "127.0.0.1")
system1.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system1))
system2 := core.NewRecord(systemCollection)
system2.Set("name", "system-2")
system2.Set("status", "up")
system2.Set("host", "127.0.0.2")
system2.Set("users", []string{user.Id})
require.NoError(t, hub.Save(system2))
alertCollection, err := hub.FindCollectionByNameOrId("alerts")
require.NoError(t, err)
alert1 := core.NewRecord(alertCollection)
alert1.Set("user", user.Id)
alert1.Set("system", system1.Id)
alert1.Set("name", "Status")
alert1.Set("triggered", false)
alert1.Set("min", 5)
require.NoError(t, hub.Save(alert1))
alert2 := core.NewRecord(alertCollection)
alert2.Set("user", user.Id)
alert2.Set("system", system2.Id)
alert2.Set("name", "Status")
alert2.Set("triggered", false)
alert2.Set("min", 5)
require.NoError(t, hub.Save(alert2))
am := alerts.NewTestAlertManagerWithoutWorker(hub)
initialEmailCount := hub.TestMailer.TotalSend()
// Both systems go down
require.NoError(t, am.HandleStatusAlerts("down", system1))
require.NoError(t, am.HandleStatusAlerts("down", system2))
assert.Equal(t, 2, am.GetPendingAlertsCount(), "both systems should have pending alerts")
// System 1 is paused — cancel its pending alerts
am.CancelPendingStatusAlerts(system1.Id)
assert.Equal(t, 1, am.GetPendingAlertsCount(), "only system2 alert should remain pending after pausing system1")
// Expire and process remaining alerts — only system2 should fire
am.ForceExpirePendingAlerts()
processed, err := am.ProcessPendingAlerts()
require.NoError(t, err)
assert.Len(t, processed, 1, "only the non-paused system's alert should be processed")
assert.Equal(t, initialEmailCount+1, hub.TestMailer.TotalSend(), "only system2 should send a down notification")
}

View File

@@ -48,6 +48,8 @@ type Stats struct {
MaxDiskIO [2]uint64 `json:"diom,omitzero" cbor:"-"` // [max read bytes, max write bytes]
CpuBreakdown []float64 `json:"cpub,omitempty" cbor:"33,keyasint,omitempty"` // [user, system, iowait, steal, idle]
CpuCoresUsage Uint8Slice `json:"cpus,omitempty" cbor:"34,keyasint,omitempty"` // per-core busy usage [CPU0..]
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"35,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
}
// Uint8Slice wraps []uint8 to customize JSON encoding while keeping CBOR efficient.
@@ -93,10 +95,12 @@ type FsStats struct {
MaxDiskReadPS float64 `json:"rm,omitempty" cbor:"-"`
MaxDiskWritePS float64 `json:"wm,omitempty" cbor:"-"`
// TODO: remove DiskReadPs and DiskWritePs in future release in favor of DiskReadBytes and DiskWriteBytes
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
DiskReadBytes uint64 `json:"rb" cbor:"6,keyasint,omitempty"`
DiskWriteBytes uint64 `json:"wb" cbor:"7,keyasint,omitempty"`
MaxDiskReadBytes uint64 `json:"rbm,omitempty" cbor:"-"`
MaxDiskWriteBytes uint64 `json:"wbm,omitempty" cbor:"-"`
DiskIoStats [6]float64 `json:"dios,omitzero" cbor:"8,keyasint,omitzero"` // [read time %, write time %, io utilization %, r_await ms, w_await ms, weighted io %]
MaxDiskIoStats [6]float64 `json:"diosm,omitzero" cbor:"-"` // max values for DiskIoStats
}
type NetIoStats struct {

View File

@@ -110,21 +110,13 @@ func (p *updater) update() (updated bool, err error) {
}
var latest *release
var useMirror bool
// Determine the API endpoint based on UseMirror flag
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", p.config.Owner, p.config.Repo)
apiURL := getApiURL(p.config.UseMirror, p.config.Owner, p.config.Repo)
if p.config.UseMirror {
useMirror = true
apiURL = fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", p.config.Owner, p.config.Repo)
ColorPrint(ColorYellow, "Using mirror for update.")
}
latest, err = fetchLatestRelease(
p.config.Context,
p.config.HttpClient,
apiURL,
)
latest, err = FetchLatestRelease(p.config.Context, p.config.HttpClient, apiURL)
if err != nil {
return false, err
}
@@ -150,7 +142,7 @@ func (p *updater) update() (updated bool, err error) {
// download the release asset
assetPath := filepath.Join(releaseDir, asset.Name)
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, useMirror); err != nil {
if err := downloadFile(p.config.Context, p.config.HttpClient, asset.DownloadUrl, assetPath, p.config.UseMirror); err != nil {
return false, err
}
@@ -226,11 +218,11 @@ func (p *updater) update() (updated bool, err error) {
return true, nil
}
func fetchLatestRelease(
ctx context.Context,
client HttpClient,
url string,
) (*release, error) {
func FetchLatestRelease(ctx context.Context, client HttpClient, url string) (*release, error) {
if url == "" {
url = getApiURL(false, "henrygd", "beszel")
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
@@ -375,3 +367,10 @@ func isGlibc() bool {
}
return false
}
func getApiURL(useMirror bool, owner, repo string) string {
if useMirror {
return fmt.Sprintf("https://gh.beszel.dev/repos/%s/%s/releases/latest?api=true", owner, repo)
}
return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo)
}

390
internal/hub/api.go Normal file
View File

@@ -0,0 +1,390 @@
package hub
import (
"context"
"net/http"
"regexp"
"strings"
"time"
"github.com/blang/semver"
"github.com/google/uuid"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/ghupdate"
"github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/systems"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
)
// UpdateInfo holds information about the latest update check
type UpdateInfo struct {
lastCheck time.Time
Version string `json:"v"`
Url string `json:"url"`
}
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
// Middleware to allow only admin role users
var requireAdminRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
return e.Auth.GetString("role") == "admin"
})
// Middleware to exclude readonly users
var excludeReadOnlyRole = customAuthMiddleware(func(e *core.RequestEvent) bool {
return e.Auth.GetString("role") != "readonly"
})
// customAuthMiddleware handles boilerplate for custom authentication middlewares. fn should
// return true if the request is allowed, false otherwise. e.Auth is guaranteed to be non-nil.
func customAuthMiddleware(fn func(*core.RequestEvent) bool) func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
if e.Auth == nil {
return e.UnauthorizedError("The request requires valid record authorization token.", nil)
}
if !fn(e) {
return e.ForbiddenError("The authorized record is not allowed to perform this action.", nil)
}
return e.Next()
}
}
// registerMiddlewares registers custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
// authorizes request with user matching the provided email
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
if e.Auth != nil || email == "" {
return e.Next()
}
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
e.Auth, err = e.App.FindAuthRecordByEmail("users", email)
if err != nil || !isAuthRefresh {
return e.Next()
}
// auth refresh endpoint, make sure token is set in header
token, _ := e.Auth.NewAuthToken()
e.Request.Header.Set("Authorization", token)
return e.Next()
}
// authenticate with trusted header
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, autoLogin)
})
}
// authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
}
}
// registerApiRoutes registers custom API routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes
apiAuth := se.Router.Group("/api/beszel")
apiAuth.Bind(apis.RequireAuth())
// auth optional routes
apiNoAuth := se.Router.Group("/api/beszel")
// create first user endpoint only needed if no users exist
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
}
// check if first time setup on login page
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
total, err := e.App.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// get public key and version
apiAuth.GET("/info", h.getInfo)
apiAuth.GET("/getkey", h.getInfo) // deprecated - keep for compatibility w/ integrations
// check for updates
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
var updateInfo UpdateInfo
apiAuth.GET("/update", updateInfo.getUpdate)
}
// send test notification
apiAuth.POST("/test-notification", h.SendTestNotification)
// heartbeat status and test
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus).BindFunc(requireAdminRole)
apiAuth.POST("/test-heartbeat", h.testHeartbeat).BindFunc(requireAdminRole)
// get config.yml content
apiAuth.GET("/config-yaml", config.GetYamlConfig).BindFunc(requireAdminRole)
// handle agent websocket connection
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
// get or create universal tokens
apiAuth.GET("/universal-token", h.getUniversalToken).BindFunc(excludeReadOnlyRole)
// update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData).BindFunc(excludeReadOnlyRole)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info
apiAuth.GET("/containers/info", h.getContainerInfo)
}
return nil
}
// getInfo returns data needed by authenticated users, such as the public key and current version
func (h *Hub) getInfo(e *core.RequestEvent) error {
type infoResponse struct {
Key string `json:"key"`
Version string `json:"v"`
CheckUpdate bool `json:"cu"`
}
info := infoResponse{
Key: h.pubKey,
Version: beszel.Version,
}
if optIn, _ := GetEnv("CHECK_UPDATES"); optIn == "true" {
info.CheckUpdate = true
}
return e.JSON(http.StatusOK, info)
}
// getUpdate checks for the latest release on GitHub and returns update info if a newer version is available
func (info *UpdateInfo) getUpdate(e *core.RequestEvent) error {
if time.Since(info.lastCheck) < 6*time.Hour {
return e.JSON(http.StatusOK, info)
}
info.lastCheck = time.Now()
latestRelease, err := ghupdate.FetchLatestRelease(context.Background(), http.DefaultClient, "")
if err != nil {
return err
}
currentVersion, err := semver.Parse(strings.TrimPrefix(beszel.Version, "v"))
if err != nil {
return err
}
latestVersion, err := semver.Parse(strings.TrimPrefix(latestRelease.Tag, "v"))
if err != nil {
return err
}
if latestVersion.GT(currentVersion) {
info.Version = strings.TrimPrefix(latestRelease.Tag, "v")
info.Url = latestRelease.Url
}
return e.JSON(http.StatusOK, info)
}
// GetUniversalToken handles the universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
if e.Auth.IsSuperuser() {
return e.ForbiddenError("Superusers cannot use universal tokens", nil)
}
tokenMap := universalTokenMap.GetMap()
userID := e.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
enable := query.Get("enable")
permanent := query.Get("permanent")
// helper for deleting any existing permanent token record for this user
deletePermanent := func() error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err != nil {
return nil // no record
}
return h.Delete(rec)
}
// helper for upserting a permanent token record for this user
upsertPermanent := func(token string) error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err == nil {
rec.Set("token", token)
return h.Save(rec)
}
col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
if err != nil {
return err
}
newRec := core.NewRecord(col)
newRec.Set("user", userID)
newRec.Set("token", token)
return h.Save(newRec)
}
// Disable universal tokens (both ephemeral and permanent)
if enable == "0" {
tokenMap.RemovebyValue(userID)
_ = deletePermanent()
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// Enable universal token (ephemeral or permanent)
if enable == "1" {
if token == "" {
token = uuid.New().String()
}
if permanent == "1" {
// make token permanent (persist across restarts)
tokenMap.RemovebyValue(userID)
if err := upsertPermanent(token); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
}
// default: ephemeral mode (1 hour)
_ = deletePermanent()
tokenMap.Set(token, userID, time.Hour)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// Read current state
// Prefer permanent token if it exists.
if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
dbToken := rec.GetString("token")
// If no token was provided, or the caller is asking about their permanent token, return it.
if token == "" || token == dbToken {
return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
}
// Token doesn't match their permanent token (avoid leaking other info)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// No permanent token; fall back to ephemeral token map.
if token == "" {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// if no token is provided, generate a new one
token = uuid.New().String()
}
// Token is considered active only if it belongs to the current user.
activeUser, ok := tokenMap.GetOk(token)
active := ok && activeUser == userID
response := map[string]any{"token": token, "active": active, "permanent": false}
return e.JSON(http.StatusOK, response)
}
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"enabled": false,
"msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring",
})
}
cfg := h.hb.GetConfig()
return e.JSON(http.StatusOK, map[string]any{
"enabled": true,
"url": cfg.URL,
"interval": cfg.Interval,
"method": cfg.Method,
})
}
// testHeartbeat triggers a single heartbeat ping and returns the result
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
})
}
if err := h.hb.Send(); err != nil {
return e.JSON(http.StatusOK, map[string]any{"err": err.Error()})
}
return e.JSON(http.StatusOK, map[string]any{"err": false})
}
// containerRequestHandler handles both container logs and info requests
func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
systemID := e.Request.URL.Query().Get("system")
containerID := e.Request.URL.Query().Get("container")
if systemID == "" || containerID == "" || !containerIDPattern.MatchString(containerID) {
return e.BadRequestError("Invalid system or container parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
return e.NotFoundError("", nil)
}
data, err := fetchFunc(system, containerID)
if err != nil {
return e.InternalServerError("", err)
}
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
}
// getContainerLogs handles GET /api/beszel/containers/logs requests
func (h *Hub) getContainerLogs(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerLogsFromAgent(containerID)
}, "logs")
}
func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerInfoFromAgent(containerID)
}, "info")
}
// getSystemdInfo handles GET /api/beszel/systemd/info requests
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.BadRequestError("Invalid system or service parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
return e.NotFoundError("", nil)
}
// verify service exists before fetching details
_, err = e.App.FindFirstRecordByFilter("systemd_services", "system = {:system} && name = {:name}", dbx.Params{
"system": systemID,
"name": serviceName,
})
if err != nil {
return e.NotFoundError("", err)
}
details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil {
return e.InternalServerError("", err)
}
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.BadRequestError("Invalid system parameter", nil)
}
system, err := h.sm.GetSystem(systemID)
if err != nil || !system.HasUser(e.App, e.Auth.Id) {
return e.NotFoundError("", nil)
}
if err := system.FetchAndSaveSmartDevices(); err != nil {
return e.InternalServerError("", err)
}
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}

980
internal/hub/api_test.go Normal file
View File

@@ -0,0 +1,980 @@
package hub_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"testing"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/henrygd/beszel/internal/migrations"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/require"
)
// 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 TestApiRoutesAuthentication(t *testing.T) {
hub, user := beszelTests.GetHubWithUser(t)
defer hub.Cleanup()
userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create auth token")
// Create test user and get auth token
user2, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user")
user2Token, err := user2.NewAuthToken()
require.NoError(t, err, "Failed to create user2 auth token")
adminUser, err := beszelTests.CreateUserWithRole(hub, "admin@example.com", "password123", "admin")
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
readOnlyUser, err := beszelTests.CreateUserWithRole(hub, "readonly@example.com", "password123", "readonly")
require.NoError(t, err, "Failed to create readonly user")
readOnlyUserToken, err := readOnlyUser.NewAuthToken()
require.NoError(t, err, "Failed to create readonly user auth token")
superuser, err := beszelTests.CreateSuperuser(hub, "superuser@example.com", "password123")
require.NoError(t, err, "Failed to create superuser")
superuserToken, err := superuser.NewAuthToken()
require.NoError(t, err, "Failed to create superuser auth token")
// Create test system
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
require.NoError(t, err, "Failed to create test system")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// Auth Protected Routes - Should require authentication
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"sending message"},
},
{
Name: "GET /config-yaml - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"test-system"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{`"enabled":false`},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with admin auth should report disabled state",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"Heartbeat not configured"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - with auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"active", "token", "permanent"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - enable permanent should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - superuser should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": superuserToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Superusers cannot use universal tokens"},
TestAppFactory: func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
},
},
{
Name: "GET /universal-token - with readonly auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": readOnlyUserToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - missing system should fail 400 with user auth",
Method: http.MethodPost,
URL: "/api/beszel/smart/refresh",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "system", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - with readonly auth should fail",
Method: http.MethodPost,
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
Headers: map[string]string{
"Authorization": readOnlyUserToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"The authorized record is not allowed to perform this action."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - non-user system should fail",
Method: http.MethodPost,
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
Headers: map[string]string{
"Authorization": user2Token,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /smart/refresh - good user should pass validation",
Method: http.MethodPost,
URL: fmt.Sprintf("/api/beszel/smart/refresh?system=%s", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "POST /user-alerts - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - no auth should fail",
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{system.Id},
}),
},
{
Name: "DELETE /user-alerts - with auth should succeed",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
// Create an alert to delete
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 10,
})
},
},
{
Name: "GET /containers/logs - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system&container=abababababab",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/containers/logs?system=%s&container=abababababab", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{
Name: "GET /containers/info - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/containers/info?system=%s&container=abababababab", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{
Name: "GET /containers/logs - with auth but missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?container=abababababab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but missing container param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - non-hex container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - good user should pass validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - good user should pass validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
},
// /systemd routes
{
Name: "GET /systemd/info - no auth should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - request for valid non-user system should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": user2Token,
},
},
{
Name: "GET /systemd/info - with auth but missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/systemd/info?service=nginx.service",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - with auth but missing service param should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"Invalid", "parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/systemd/info?system=invalid-system&service=nginx.service",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - service not in systemd_services collection should fail",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=notregistered.service", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"The requested resource wasn't found."},
TestAppFactory: testAppFactory,
},
{
Name: "GET /systemd/info - with auth and existing service record should pass validation",
Method: http.MethodGet,
URL: fmt.Sprintf("/api/beszel/systemd/info?system=%s&service=nginx.service", system.Id),
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 500,
ExpectedContent: []string{"Something went wrong while processing your request."},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateRecord(app, "systemd_services", map[string]any{
"system": system.Id,
"name": "nginx.service",
"state": 0,
"sub": 1,
})
},
},
// Auth Optional Routes - Should work without authentication
{
Name: "GET /getkey - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /info - should return the same as /getkey",
Method: http.MethodGet,
URL: "/api/beszel/info",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - no auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
Method: http.MethodGet,
URL: "/api/beszel/agent-connect",
ExpectedStatus: 400,
ExpectedContent: []string{},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-notification - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
// this works but diff behavior on prod vs dev.
// dev returns 502; prod returns 200 with static html page 404
// TODO: align dev and prod behavior and re-enable this test
// {
// Name: "GET /update - shouldn't exist without CHECK_UPDATES env var",
// Method: http.MethodGet,
// URL: "/api/beszel/update",
// NotExpectedContent: []string{"v:", "\"v\":"},
// ExpectedStatus: 502,
// TestAppFactory: testAppFactory,
// },
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestFirstUserCreation(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactoryExisting,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser")
require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should have created one superuser")
require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
},
{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactoryExisting,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
})
t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) {
t.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com")
t.Setenv("BESZEL_HUB_USER_PASSWORD", "password123")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should start with one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should still have one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should still have one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
}
scenario.Test(t)
})
}
func TestCreateUserEndpointAvailability(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Ensure no users exist
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
// Verify user was created
userCount, err = hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
})
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Create a user first
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
require.NoError(t, err)
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "another@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
})
}
func TestAutoLoginMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
t.Setenv("AUTO_LOGIN", "user@test.com")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without auto login should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestTrustedHeaderMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without trusted header should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestUpdateEndpoint(t *testing.T) {
t.Setenv("CHECK_UPDATES", "true")
hub, _ := beszelTests.NewTestHub(t.TempDir())
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")
// userToken, err := user.NewAuthToken()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "update endpoint shouldn't work without auth",
Method: http.MethodGet,
URL: "/api/beszel/update",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
// leave this out for now since it actually makes a request to github
// {
// Name: "GET /update - with valid auth should succeed",
// Method: http.MethodGet,
// URL: "/api/beszel/update",
// Headers: map[string]string{
// "Authorization": userToken,
// },
// ExpectedStatus: 200,
// ExpectedContent: []string{`"v":`},
// TestAppFactory: testAppFactory,
// },
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}

View File

@@ -279,9 +279,6 @@ func createFingerprintRecord(app core.App, systemID, token string) error {
// Returns the current config.yml file as a JSON object
func GetYamlConfig(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
configContent, err := generateYAML(e.App)
if err != nil {
return err

View File

@@ -6,15 +6,11 @@ import (
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/url"
"os"
"path"
"regexp"
"strings"
"time"
"github.com/henrygd/beszel"
"github.com/henrygd/beszel/internal/alerts"
"github.com/henrygd/beszel/internal/hub/config"
"github.com/henrygd/beszel/internal/hub/heartbeat"
@@ -22,10 +18,7 @@ import (
"github.com/henrygd/beszel/internal/records"
"github.com/henrygd/beszel/internal/users"
"github.com/google/uuid"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase"
"github.com/pocketbase/pocketbase/apis"
"github.com/pocketbase/pocketbase/core"
"golang.org/x/crypto/ssh"
)
@@ -44,8 +37,6 @@ type Hub struct {
appURL string
}
var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`)
// NewHub creates a new Hub instance with default configuration
func NewHub(app core.App) *Hub {
hub := &Hub{App: app}
@@ -160,295 +151,6 @@ func (h *Hub) registerCronJobs(_ *core.ServeEvent) error {
return nil
}
// registerMiddlewares registers custom middlewares
func (h *Hub) registerMiddlewares(se *core.ServeEvent) {
// authorizes request with user matching the provided email
authorizeRequestWithEmail := func(e *core.RequestEvent, email string) (err error) {
if e.Auth != nil || email == "" {
return e.Next()
}
isAuthRefresh := e.Request.URL.Path == "/api/collections/users/auth-refresh" && e.Request.Method == http.MethodPost
e.Auth, err = e.App.FindFirstRecordByData("users", "email", email)
if err != nil || !isAuthRefresh {
return e.Next()
}
// auth refresh endpoint, make sure token is set in header
token, _ := e.Auth.NewAuthToken()
e.Request.Header.Set("Authorization", token)
return e.Next()
}
// authenticate with trusted header
if autoLogin, _ := GetEnv("AUTO_LOGIN"); autoLogin != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, autoLogin)
})
}
// authenticate with trusted header
if trustedHeader, _ := GetEnv("TRUSTED_AUTH_HEADER"); trustedHeader != "" {
se.Router.BindFunc(func(e *core.RequestEvent) error {
return authorizeRequestWithEmail(e, e.Request.Header.Get(trustedHeader))
})
}
}
// registerApiRoutes registers custom API routes
func (h *Hub) registerApiRoutes(se *core.ServeEvent) error {
// auth protected routes
apiAuth := se.Router.Group("/api/beszel")
apiAuth.Bind(apis.RequireAuth())
// auth optional routes
apiNoAuth := se.Router.Group("/api/beszel")
// create first user endpoint only needed if no users exist
if totalUsers, _ := se.App.CountRecords("users"); totalUsers == 0 {
apiNoAuth.POST("/create-user", h.um.CreateFirstUser)
}
// check if first time setup on login page
apiNoAuth.GET("/first-run", func(e *core.RequestEvent) error {
total, err := e.App.CountRecords("users")
return e.JSON(http.StatusOK, map[string]bool{"firstRun": err == nil && total == 0})
})
// get public key and version
apiAuth.GET("/getkey", func(e *core.RequestEvent) error {
return e.JSON(http.StatusOK, map[string]string{"key": h.pubKey, "v": beszel.Version})
})
// send test notification
apiAuth.POST("/test-notification", h.SendTestNotification)
// heartbeat status and test
apiAuth.GET("/heartbeat-status", h.getHeartbeatStatus)
apiAuth.POST("/test-heartbeat", h.testHeartbeat)
// get config.yml content
apiAuth.GET("/config-yaml", config.GetYamlConfig)
// handle agent websocket connection
apiNoAuth.GET("/agent-connect", h.handleAgentConnect)
// get or create universal tokens
apiAuth.GET("/universal-token", h.getUniversalToken)
// update / delete user alerts
apiAuth.POST("/user-alerts", alerts.UpsertUserAlerts)
apiAuth.DELETE("/user-alerts", alerts.DeleteUserAlerts)
// refresh SMART devices for a system
apiAuth.POST("/smart/refresh", h.refreshSmartData)
// get systemd service details
apiAuth.GET("/systemd/info", h.getSystemdInfo)
// /containers routes
if enabled, _ := GetEnv("CONTAINER_DETAILS"); enabled != "false" {
// get container logs
apiAuth.GET("/containers/logs", h.getContainerLogs)
// get container info
apiAuth.GET("/containers/info", h.getContainerInfo)
}
return nil
}
// GetUniversalToken handles the universal token API endpoint (create, read, delete)
func (h *Hub) getUniversalToken(e *core.RequestEvent) error {
tokenMap := universalTokenMap.GetMap()
userID := e.Auth.Id
query := e.Request.URL.Query()
token := query.Get("token")
enable := query.Get("enable")
permanent := query.Get("permanent")
// helper for deleting any existing permanent token record for this user
deletePermanent := func() error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err != nil {
return nil // no record
}
return h.Delete(rec)
}
// helper for upserting a permanent token record for this user
upsertPermanent := func(token string) error {
rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID})
if err == nil {
rec.Set("token", token)
return h.Save(rec)
}
col, err := h.FindCachedCollectionByNameOrId("universal_tokens")
if err != nil {
return err
}
newRec := core.NewRecord(col)
newRec.Set("user", userID)
newRec.Set("token", token)
return h.Save(newRec)
}
// Disable universal tokens (both ephemeral and permanent)
if enable == "0" {
tokenMap.RemovebyValue(userID)
_ = deletePermanent()
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// Enable universal token (ephemeral or permanent)
if enable == "1" {
if token == "" {
token = uuid.New().String()
}
if permanent == "1" {
// make token permanent (persist across restarts)
tokenMap.RemovebyValue(userID)
if err := upsertPermanent(token); err != nil {
return err
}
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": true})
}
// default: ephemeral mode (1 hour)
_ = deletePermanent()
tokenMap.Set(token, userID, time.Hour)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// Read current state
// Prefer permanent token if it exists.
if rec, err := h.FindFirstRecordByFilter("universal_tokens", "user = {:user}", dbx.Params{"user": userID}); err == nil {
dbToken := rec.GetString("token")
// If no token was provided, or the caller is asking about their permanent token, return it.
if token == "" || token == dbToken {
return e.JSON(http.StatusOK, map[string]any{"token": dbToken, "active": true, "permanent": true})
}
// Token doesn't match their permanent token (avoid leaking other info)
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": false, "permanent": false})
}
// No permanent token; fall back to ephemeral token map.
if token == "" {
// return existing token if it exists
if token, _, ok := tokenMap.GetByValue(userID); ok {
return e.JSON(http.StatusOK, map[string]any{"token": token, "active": true, "permanent": false})
}
// if no token is provided, generate a new one
token = uuid.New().String()
}
// Token is considered active only if it belongs to the current user.
activeUser, ok := tokenMap.GetOk(token)
active := ok && activeUser == userID
response := map[string]any{"token": token, "active": active, "permanent": false}
return e.JSON(http.StatusOK, response)
}
// getHeartbeatStatus returns current heartbeat configuration and whether it's enabled
func (h *Hub) getHeartbeatStatus(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"enabled": false,
"msg": "Set HEARTBEAT_URL to enable outbound heartbeat monitoring",
})
}
cfg := h.hb.GetConfig()
return e.JSON(http.StatusOK, map[string]any{
"enabled": true,
"url": cfg.URL,
"interval": cfg.Interval,
"method": cfg.Method,
})
}
// testHeartbeat triggers a single heartbeat ping and returns the result
func (h *Hub) testHeartbeat(e *core.RequestEvent) error {
if e.Auth.GetString("role") != "admin" {
return e.ForbiddenError("Requires admin role", nil)
}
if h.hb == nil {
return e.JSON(http.StatusOK, map[string]any{
"err": "Heartbeat not configured. Set HEARTBEAT_URL environment variable.",
})
}
if err := h.hb.Send(); err != nil {
return e.JSON(http.StatusOK, map[string]any{"err": err.Error()})
}
return e.JSON(http.StatusOK, map[string]any{"err": false})
}
// containerRequestHandler handles both container logs and info requests
func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*systems.System, string) (string, error), responseKey string) error {
systemID := e.Request.URL.Query().Get("system")
containerID := e.Request.URL.Query().Get("container")
if systemID == "" || containerID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"})
}
if !containerIDPattern.MatchString(containerID) {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
data, err := fetchFunc(system, containerID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{responseKey: data})
}
// getContainerLogs handles GET /api/beszel/containers/logs requests
func (h *Hub) getContainerLogs(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerLogsFromAgent(containerID)
}, "logs")
}
func (h *Hub) getContainerInfo(e *core.RequestEvent) error {
return h.containerRequestHandler(e, func(system *systems.System, containerID string) (string, error) {
return system.FetchContainerInfoFromAgent(containerID)
}, "info")
}
// getSystemdInfo handles GET /api/beszel/systemd/info requests
func (h *Hub) getSystemdInfo(e *core.RequestEvent) error {
query := e.Request.URL.Query()
systemID := query.Get("system")
serviceName := query.Get("service")
if systemID == "" || serviceName == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and service parameters are required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
details, err := system.FetchSystemdInfoFromAgent(serviceName)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": err.Error()})
}
e.Response.Header().Set("Cache-Control", "public, max-age=60")
return e.JSON(http.StatusOK, map[string]any{"details": details})
}
// refreshSmartData handles POST /api/beszel/smart/refresh requests
// Fetches fresh SMART data from the agent and updates the collection
func (h *Hub) refreshSmartData(e *core.RequestEvent) error {
systemID := e.Request.URL.Query().Get("system")
if systemID == "" {
return e.JSON(http.StatusBadRequest, map[string]string{"error": "system parameter is required"})
}
system, err := h.sm.GetSystem(systemID)
if err != nil {
return e.JSON(http.StatusNotFound, map[string]string{"error": "system not found"})
}
// Fetch and save SMART devices
if err := system.FetchAndSaveSmartDevices(); err != nil {
return e.JSON(http.StatusInternalServerError, map[string]string{"error": err.Error()})
}
return e.JSON(http.StatusOK, map[string]string{"status": "ok"})
}
// GetSSHKey generates key pair if it doesn't exist and returns signer
func (h *Hub) GetSSHKey(dataDir string) (ssh.Signer, error) {
if h.signer != nil {

View File

@@ -3,36 +3,20 @@
package hub_test
import (
"bytes"
"crypto/ed25519"
"encoding/json"
"encoding/pem"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/henrygd/beszel/internal/migrations"
beszelTests "github.com/henrygd/beszel/internal/tests"
"github.com/pocketbase/pocketbase/core"
pbTests "github.com/pocketbase/pocketbase/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/crypto/ssh"
)
// 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 TestMakeLink(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
@@ -265,699 +249,6 @@ func TestGetSSHKey(t *testing.T) {
})
}
func TestApiRoutesAuthentication(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
// Create test user and get auth token
user, err := beszelTests.CreateUser(hub, "testuser@example.com", "password123")
require.NoError(t, err, "Failed to create test user")
adminUser, err := beszelTests.CreateRecord(hub, "users", map[string]any{
"email": "admin@example.com",
"password": "password123",
"role": "admin",
})
require.NoError(t, err, "Failed to create admin user")
adminUserToken, err := adminUser.NewAuthToken()
// superUser, err := beszelTests.CreateRecord(hub, core.CollectionNameSuperusers, map[string]any{
// "email": "superuser@example.com",
// "password": "password123",
// })
// require.NoError(t, err, "Failed to create superuser")
userToken, err := user.NewAuthToken()
require.NoError(t, err, "Failed to create auth token")
// Create test system for user-alerts endpoints
system, err := beszelTests.CreateRecord(hub, "systems", map[string]any{
"name": "test-system",
"users": []string{user.Id},
"host": "127.0.0.1",
})
require.NoError(t, err, "Failed to create test system")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
// Auth Protected Routes - Should require authentication
{
Name: "POST /test-notification - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
},
{
Name: "POST /test-notification - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
TestAppFactory: testAppFactory,
Headers: map[string]string{
"Authorization": userToken,
},
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"sending message"},
},
{
Name: "GET /config-yaml - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /config-yaml - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/config-yaml",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"test-system"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with user auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /heartbeat-status - with admin auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/heartbeat-status",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{`"enabled":false`},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with user auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 403,
ExpectedContent: []string{"Requires admin role"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-heartbeat - with admin auth should report disabled state",
Method: http.MethodPost,
URL: "/api/beszel/test-heartbeat",
Headers: map[string]string{
"Authorization": adminUserToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"Heartbeat not configured"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - with auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"active", "token", "permanent"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /universal-token - enable permanent should succeed",
Method: http.MethodGet,
URL: "/api/beszel/universal-token?enable=1&permanent=1&token=permanent-token-123",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"permanent\":true", "permanent-token-123"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - no auth should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "POST /user-alerts - with auth should succeed",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
{
Name: "DELETE /user-alerts - no auth should fail",
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{system.Id},
}),
},
{
Name: "DELETE /user-alerts - with auth should succeed",
Method: http.MethodDelete,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"success\":true"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"systems": []string{system.Id},
}),
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
// Create an alert to delete
beszelTests.CreateRecord(app, "alerts", map[string]any{
"name": "CPU",
"system": system.Id,
"user": user.Id,
"value": 80,
"min": 10,
})
},
},
{
Name: "GET /containers/logs - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system&container=test-container",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but missing system param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?container=test-container",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but missing container param should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=test-system",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"system and container parameters are required"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - with auth but invalid system should fail",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 404,
ExpectedContent: []string{"system not found"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/logs - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - traversal container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /containers/info - non-hex container should fail validation",
Method: http.MethodGet,
URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 400,
ExpectedContent: []string{"invalid container parameter"},
TestAppFactory: testAppFactory,
},
// Auth Optional Routes - Should work without authentication
{
Name: "GET /getkey - no auth should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - no auth should succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /first-run - with auth should also succeed",
Method: http.MethodGet,
URL: "/api/beszel/first-run",
Headers: map[string]string{
"Authorization": userToken,
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"firstRun\":false"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /agent-connect - no auth should succeed (websocket upgrade fails but route is accessible)",
Method: http.MethodGet,
URL: "/api/beszel/agent-connect",
ExpectedStatus: 400,
ExpectedContent: []string{},
TestAppFactory: testAppFactory,
},
{
Name: "POST /test-notification - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/test-notification",
Body: jsonReader(map[string]any{
"url": "generic://127.0.0.1",
}),
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "POST /user-alerts - invalid auth token should fail",
Method: http.MethodPost,
URL: "/api/beszel/user-alerts",
Headers: map[string]string{
"Authorization": "invalid-token",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
Body: jsonReader(map[string]any{
"name": "CPU",
"value": 80,
"min": 10,
"systems": []string{system.Id},
}),
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestFirstUserCreation(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactoryExisting := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactoryExisting,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one temporary superuser")
require.EqualValues(t, migrations.TempAdminEmail, superusers[0].GetString("email"), "Should have created one temporary superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should have created one superuser")
require.EqualValues(t, "firstuser@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
},
{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactoryExisting,
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
})
t.Run("CreateUserEndpoint not available when USER_EMAIL, USER_PASSWORD are set", func(t *testing.T) {
t.Setenv("BESZEL_HUB_USER_EMAIL", "me@example.com")
t.Setenv("BESZEL_HUB_USER_PASSWORD", "password123")
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when USER_EMAIL, USER_PASSWORD are set",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should start with one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should start with one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
AfterTestFunc: func(t testing.TB, app *pbTests.TestApp, res *http.Response) {
users, err := hub.FindAllRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, len(users), "Should still have one user")
require.EqualValues(t, "me@example.com", users[0].GetString("email"), "Should have created one user")
superusers, err := hub.FindAllRecords(core.CollectionNameSuperusers)
require.NoError(t, err)
require.EqualValues(t, 1, len(superusers), "Should still have one superuser")
require.EqualValues(t, "me@example.com", superusers[0].GetString("email"), "Should have created one superuser")
},
}
scenario.Test(t)
})
}
func TestCreateUserEndpointAvailability(t *testing.T) {
t.Run("CreateUserEndpoint available when no users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Ensure no users exist
userCount, err := hub.CountRecords("users")
require.NoError(t, err)
require.Zero(t, userCount, "Should start with no users")
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should be available when no users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "firstuser@example.com",
"password": "password123",
}),
ExpectedStatus: 200,
ExpectedContent: []string{"User created"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
// Verify user was created
userCount, err = hub.CountRecords("users")
require.NoError(t, err)
require.EqualValues(t, 1, userCount, "Should have created one user")
})
t.Run("CreateUserEndpoint not available when users exist", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())
defer hub.Cleanup()
// Create a user first
_, err := beszelTests.CreateUser(hub, "existing@example.com", "password")
require.NoError(t, err)
hub.StartHub()
testAppFactory := func(t testing.TB) *pbTests.TestApp {
return hub.TestApp
}
scenario := beszelTests.ApiScenario{
Name: "POST /create-user - should not be available when users exist",
Method: http.MethodPost,
URL: "/api/beszel/create-user",
Body: jsonReader(map[string]any{
"email": "another@example.com",
"password": "password123",
}),
ExpectedStatus: 404,
ExpectedContent: []string{"wasn't found"},
TestAppFactory: testAppFactory,
}
scenario.Test(t)
})
}
func TestAutoLoginMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
t.Setenv("AUTO_LOGIN", "user@test.com")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without auto login should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with auto login should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestTrustedHeaderMiddleware(t *testing.T) {
var hubs []*beszelTests.TestHub
defer func() {
for _, hub := range hubs {
hub.Cleanup()
}
}()
t.Setenv("TRUSTED_AUTH_HEADER", "X-Beszel-Trusted")
testAppFactory := func(t testing.TB) *pbTests.TestApp {
hub, _ := beszelTests.NewTestHub(t.TempDir())
hubs = append(hubs, hub)
hub.StartHub()
return hub.TestApp
}
scenarios := []beszelTests.ApiScenario{
{
Name: "GET /getkey - without trusted header should fail",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should fail if no matching user",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 401,
ExpectedContent: []string{"requires valid"},
TestAppFactory: testAppFactory,
},
{
Name: "GET /getkey - with trusted header should succeed",
Method: http.MethodGet,
URL: "/api/beszel/getkey",
Headers: map[string]string{
"X-Beszel-Trusted": "user@test.com",
},
ExpectedStatus: 200,
ExpectedContent: []string{"\"key\":", "\"v\":"},
TestAppFactory: testAppFactory,
BeforeTestFunc: func(t testing.TB, app *pbTests.TestApp, e *core.ServeEvent) {
beszelTests.CreateUser(app, "user@test.com", "password123")
},
},
}
for _, scenario := range scenarios {
scenario.Test(t)
}
}
func TestAppUrl(t *testing.T) {
t.Run("no APP_URL does't change app url", func(t *testing.T) {
hub, _ := beszelTests.NewTestHub(t.TempDir())

View File

@@ -5,7 +5,6 @@ package hub
import (
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httputil"
"net/url"
@@ -62,7 +61,6 @@ func (rm *responseModifier) modifyHTML(html string) string {
// startServer sets up the development server for Beszel
func (h *Hub) startServer(se *core.ServeEvent) error {
slog.Info("starting server", "appURL", h.appURL)
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
Scheme: "http",
Host: "localhost:5173",

View File

@@ -8,6 +8,7 @@ import (
"hash/fnv"
"math/rand"
"net"
"slices"
"strings"
"sync/atomic"
"time"
@@ -145,6 +146,7 @@ func (sys *System) update() error {
// update smart interval if it's set on the agent side
if data.Details.SmartInterval > 0 {
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
// to prevent premature expiration leading to new fetch if interval is different.
sys.manager.smartFetchMap.UpdateExpiration(sys.Id, sys.smartInterval+time.Minute)
@@ -156,11 +158,10 @@ func (sys *System) update() error {
if sys.smartInterval <= 0 {
sys.smartInterval = time.Hour
}
lastFetch, _ := sys.manager.smartFetchMap.GetOk(sys.Id)
if time.Since(time.UnixMilli(lastFetch-1e4)) >= sys.smartInterval && sys.smartFetching.CompareAndSwap(false, true) {
if sys.shouldFetchSmart() && sys.smartFetching.CompareAndSwap(false, true) {
sys.manager.hub.Logger().Info("SMART fetch", "system", sys.Id, "interval", sys.smartInterval.String())
go func() {
defer sys.smartFetching.Store(false)
sys.manager.smartFetchMap.Set(sys.Id, time.Now().UnixMilli(), sys.smartInterval+time.Minute)
_ = sys.FetchAndSaveSmartDevices()
}()
}
@@ -184,7 +185,7 @@ func (sys *System) handlePaused() {
// createRecords updates the system record and adds system_stats and container_stats records
func (sys *System) createRecords(data *system.CombinedData) (*core.Record, error) {
systemRecord, err := sys.getRecord()
systemRecord, err := sys.getRecord(sys.manager.hub)
if err != nil {
return nil, err
}
@@ -343,8 +344,8 @@ func createContainerRecords(app core.App, data []*container.Stats, systemId stri
// getRecord retrieves the system record from the database.
// If the record is not found, it removes the system from the manager.
func (sys *System) getRecord() (*core.Record, error) {
record, err := sys.manager.hub.FindRecordById("systems", sys.Id)
func (sys *System) getRecord(app core.App) (*core.Record, error) {
record, err := app.FindRecordById("systems", sys.Id)
if err != nil || record == nil {
_ = sys.manager.RemoveSystem(sys.Id)
return nil, err
@@ -352,6 +353,16 @@ func (sys *System) getRecord() (*core.Record, error) {
return record, nil
}
// HasUser checks if the given user ID is in the system's users list.
func (sys *System) HasUser(app core.App, userID string) bool {
record, err := sys.getRecord(app)
if err != nil {
return false
}
users := record.GetStringSlice("users")
return slices.Contains(users, userID)
}
// setDown marks a system as down in the database.
// It takes the original error that caused the system to go down and returns any error
// encountered during the process of updating the system status.
@@ -359,7 +370,7 @@ func (sys *System) setDown(originalError error) error {
if sys.Status == down || sys.Status == paused {
return nil
}
record, err := sys.getRecord()
record, err := sys.getRecord(sys.manager.hub)
if err != nil {
return err
}
@@ -643,6 +654,7 @@ func (s *System) createSSHClient() error {
return err
}
s.agentVersion, _ = extractAgentVersion(string(s.client.Conn.ServerVersion()))
s.manager.resetFailedSmartFetchState(s.Id)
return nil
}

View File

@@ -41,10 +41,10 @@ var errSystemExists = errors.New("system exists")
// SystemManager manages a collection of monitored systems and their connections.
// It handles system lifecycle, status updates, and maintains both SSH and WebSocket connections.
type SystemManager struct {
hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
smartFetchMap *expirymap.ExpiryMap[int64] // Stores last SMART fetch time per system ID
hub hubLike // Hub interface for database and alert operations
systems *store.Store[string, *System] // Thread-safe store of active systems
sshConfig *ssh.ClientConfig // SSH client configuration for system connections
smartFetchMap *expirymap.ExpiryMap[smartFetchState] // Stores last SMART fetch time/result; TTL is only for cleanup
}
// hubLike defines the interface requirements for the hub dependency.
@@ -54,6 +54,7 @@ type hubLike interface {
GetSSHKey(dataDir string) (ssh.Signer, error)
HandleSystemAlerts(systemRecord *core.Record, data *system.CombinedData) error
HandleStatusAlerts(status string, systemRecord *core.Record) error
CancelPendingStatusAlerts(systemID string)
}
// NewSystemManager creates a new SystemManager instance with the provided hub.
@@ -62,7 +63,7 @@ func NewSystemManager(hub hubLike) *SystemManager {
return &SystemManager{
systems: store.New(map[string]*System{}),
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()
}
_ = deactivateAlerts(e.App, e.Record.Id)
sm.hub.CancelPendingStatusAlerts(e.Record.Id)
return e.Next()
case pending:
// Resume monitoring, preferring existing WebSocket connection
@@ -306,6 +308,7 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
if err != nil {
return err
}
sm.resetFailedSmartFetchState(systemId)
system := sm.NewSystem(systemId)
system.WsConn = wsConn
@@ -317,6 +320,15 @@ func (sm *SystemManager) AddWebSocketSystem(systemId string, agentVersion semver
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
func (sm *SystemManager) createSSHClientConfig() error {
privateKey, err := sm.hub.GetSSHKey("")

View File

@@ -19,7 +19,6 @@ type subscriptionInfo struct {
var (
activeSubscriptions = make(map[string]*subscriptionInfo)
workerRunning bool
realtimeTicker *time.Ticker
tickerStopChan chan struct{}
realtimeMutex sync.Mutex
)
@@ -70,7 +69,7 @@ func (sm *SystemManager) onRealtimeSubscribeRequest(e *core.RealtimeSubscribeReq
}
// onRealtimeSubscriptionAdded initializes or starts the realtime worker when the first subscription is added.
// It ensures only one worker runs at a time and creates the ticker for periodic data fetching.
// It ensures only one worker runs at a time.
func (sm *SystemManager) onRealtimeSubscriptionAdded() {
realtimeMutex.Lock()
defer realtimeMutex.Unlock()
@@ -82,11 +81,6 @@ func (sm *SystemManager) onRealtimeSubscriptionAdded() {
tickerStopChan = make(chan struct{})
go sm.startRealtimeWorker()
}
// If no ticker exists, create one
if realtimeTicker == nil {
realtimeTicker = time.NewTicker(1 * time.Second)
}
}
// checkSubscriptions stops the realtime worker when there are no active subscriptions.
@@ -107,11 +101,6 @@ func (sm *SystemManager) checkSubscriptions() {
}
}
if realtimeTicker != nil {
realtimeTicker.Stop()
realtimeTicker = nil
}
// Mark worker as stopped (will be reset when next subscription comes in)
workerRunning = false
}
@@ -135,17 +124,16 @@ func (sm *SystemManager) removeRealtimeSubscription(subscription string, options
// It continuously fetches system data and broadcasts it to subscribed clients via WebSocket.
func (sm *SystemManager) startRealtimeWorker() {
sm.fetchRealtimeDataAndNotify()
tick := time.Tick(1 * time.Second)
for {
select {
case <-tickerStopChan:
return
case <-realtimeTicker.C:
// Check if ticker is still valid (might have been stopped)
if realtimeTicker == nil || len(activeSubscriptions) == 0 {
case <-tick:
if len(activeSubscriptions) == 0 {
return
}
// slog.Debug("activeSubscriptions", "count", len(activeSubscriptions))
sm.fetchRealtimeDataAndNotify()
}
}

View File

@@ -4,18 +4,61 @@ import (
"database/sql"
"errors"
"strings"
"time"
"github.com/henrygd/beszel/internal/entities/smart"
"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
func (sys *System) FetchAndSaveSmartDevices() error {
smartData, err := sys.FetchSmartDataFromAgent()
if err != nil || len(smartData) == 0 {
if err != nil {
sys.recordSmartFetchResult(err, 0)
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

View 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")
}

View File

@@ -111,6 +111,9 @@ func (ws *WsConn) Close(msg []byte) {
// Ping sends a ping frame to keep the connection alive.
func (ws *WsConn) Ping() error {
if ws.conn == nil {
return gws.ErrConnClosed
}
ws.conn.SetDeadline(time.Now().Add(deadline))
return ws.conn.WritePing(nil)
}

View File

@@ -230,6 +230,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.Bandwidth[1] += stats.Bandwidth[1]
sum.DiskIO[0] += stats.DiskIO[0]
sum.DiskIO[1] += stats.DiskIO[1]
for i := range stats.DiskIoStats {
sum.DiskIoStats[i] += stats.DiskIoStats[i]
}
batterySum += int(stats.Battery[0])
sum.Battery[1] = stats.Battery[1]
@@ -254,6 +257,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.MaxBandwidth[1] = max(sum.MaxBandwidth[1], stats.MaxBandwidth[1], stats.Bandwidth[1])
sum.MaxDiskIO[0] = max(sum.MaxDiskIO[0], stats.MaxDiskIO[0], stats.DiskIO[0])
sum.MaxDiskIO[1] = max(sum.MaxDiskIO[1], stats.MaxDiskIO[1], stats.DiskIO[1])
for i := range stats.DiskIoStats {
sum.MaxDiskIoStats[i] = max(sum.MaxDiskIoStats[i], stats.MaxDiskIoStats[i], stats.DiskIoStats[i])
}
// Accumulate network interfaces
if sum.NetworkInterfaces == nil {
@@ -299,6 +305,10 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
fs.DiskWriteBytes += value.DiskWriteBytes
fs.MaxDiskReadBytes = max(fs.MaxDiskReadBytes, value.MaxDiskReadBytes, value.DiskReadBytes)
fs.MaxDiskWriteBytes = max(fs.MaxDiskWriteBytes, value.MaxDiskWriteBytes, value.DiskWriteBytes)
for i := range value.DiskIoStats {
fs.DiskIoStats[i] += value.DiskIoStats[i]
fs.MaxDiskIoStats[i] = max(fs.MaxDiskIoStats[i], value.MaxDiskIoStats[i], value.DiskIoStats[i])
}
}
}
@@ -350,6 +360,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
sum.DiskWritePs = twoDecimals(sum.DiskWritePs / count)
sum.DiskIO[0] = sum.DiskIO[0] / uint64(count)
sum.DiskIO[1] = sum.DiskIO[1] / uint64(count)
for i := range sum.DiskIoStats {
sum.DiskIoStats[i] = twoDecimals(sum.DiskIoStats[i] / count)
}
sum.NetworkSent = twoDecimals(sum.NetworkSent / count)
sum.NetworkRecv = twoDecimals(sum.NetworkRecv / count)
sum.LoadAvg[0] = twoDecimals(sum.LoadAvg[0] / count)
@@ -388,6 +401,9 @@ func (rm *RecordManager) AverageSystemStats(db dbx.Builder, records RecordIds) *
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)
}
}
}

View File

@@ -41,7 +41,7 @@
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
"valibot": "^0.42.1",
"valibot": "^1.3.1",
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
@@ -927,7 +927,7 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"valibot": ["valibot@0.42.1", "", { "peerDependencies": { "typescript": ">=5" } }, "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw=="],
"valibot": ["valibot@1.3.1", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="manifest" href="./static/manifest.json" crossorigin="use-credentials" />
<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="robots" content="noindex, nofollow" />
<title>Beszel</title>

View File

@@ -1,12 +1,12 @@
{
"name": "beszel",
"version": "0.18.3",
"version": "0.18.7",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beszel",
"version": "0.18.3",
"version": "0.18.7",
"dependencies": {
"@henrygd/queue": "^1.0.7",
"@henrygd/semaphore": "^0.0.2",
@@ -44,7 +44,7 @@
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
"valibot": "^0.42.1"
"valibot": "^1.3.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",
@@ -986,29 +986,6 @@
"integrity": "sha512-N3W7MKwTRmAxOjeG0NAT18oe2Xn3KdjkpMR6crbkF1UDamMGPjyigqEsefiv+qTaxibtc1a+zXCVzb9YXANVqw==",
"license": "MIT"
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1243,9 +1220,9 @@
}
},
"node_modules/@lingui/cli/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2408,9 +2385,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.48.1.tgz",
"integrity": "sha512-rGmb8qoG/zdmKoYELCBwu7vt+9HxZ7Koos3pD0+sH5fR3u3Wb/jGcpnqxcnWsPEKDUyzeLSqksN8LJtgXjqBYw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz",
"integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==",
"cpu": [
"arm"
],
@@ -2422,9 +2399,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.48.1.tgz",
"integrity": "sha512-4e9WtTxrk3gu1DFE+imNJr4WsL13nWbD/Y6wQcyku5qadlKHY3OQ3LJ/INrrjngv2BJIHnIzbqMk1GTAC2P8yQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz",
"integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==",
"cpu": [
"arm64"
],
@@ -2436,9 +2413,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.48.1.tgz",
"integrity": "sha512-+XjmyChHfc4TSs6WUQGmVf7Hkg8ferMAE2aNYYWjiLzAS/T62uOsdfnqv+GHRjq7rKRnYh4mwWb4Hz7h/alp8A==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz",
"integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==",
"cpu": [
"arm64"
],
@@ -2450,9 +2427,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.48.1.tgz",
"integrity": "sha512-upGEY7Ftw8M6BAJyGwnwMw91rSqXTcOKZnnveKrVWsMTF8/k5mleKSuh7D4v4IV1pLxKAk3Tbs0Lo9qYmii5mQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz",
"integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==",
"cpu": [
"x64"
],
@@ -2464,9 +2441,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.48.1.tgz",
"integrity": "sha512-P9ViWakdoynYFUOZhqq97vBrhuvRLAbN/p2tAVJvhLb8SvN7rbBnJQcBu8e/rQts42pXGLVhfsAP0k9KXWa3nQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz",
"integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==",
"cpu": [
"arm64"
],
@@ -2478,9 +2455,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.48.1.tgz",
"integrity": "sha512-VLKIwIpnBya5/saccM8JshpbxfyJt0Dsli0PjXozHwbSVaHTvWXJH1bbCwPXxnMzU4zVEfgD1HpW3VQHomi2AQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz",
"integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==",
"cpu": [
"x64"
],
@@ -2492,9 +2469,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.48.1.tgz",
"integrity": "sha512-3zEuZsXfKaw8n/yF7t8N6NNdhyFw3s8xJTqjbTDXlipwrEHo4GtIKcMJr5Ed29leLpB9AugtAQpAHW0jvtKKaQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz",
"integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==",
"cpu": [
"arm"
],
@@ -2506,9 +2483,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.48.1.tgz",
"integrity": "sha512-leo9tOIlKrcBmmEypzunV/2w946JeLbTdDlwEZ7OnnsUyelZ72NMnT4B2vsikSgwQifjnJUbdXzuW4ToN1wV+Q==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz",
"integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==",
"cpu": [
"arm"
],
@@ -2520,9 +2497,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.48.1.tgz",
"integrity": "sha512-Vy/WS4z4jEyvnJm+CnPfExIv5sSKqZrUr98h03hpAMbE2aI0aD2wvK6GiSe8Gx2wGp3eD81cYDpLLBqNb2ydwQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz",
"integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==",
"cpu": [
"arm64"
],
@@ -2534,9 +2511,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.48.1.tgz",
"integrity": "sha512-x5Kzn7XTwIssU9UYqWDB9VpLpfHYuXw5c6bJr4Mzv9kIv242vmJHbI5PJJEnmBYitUIfoMCODDhR7KoZLot2VQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz",
"integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==",
"cpu": [
"arm64"
],
@@ -2547,10 +2524,24 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.48.1.tgz",
"integrity": "sha512-yzCaBbwkkWt/EcgJOKDUdUpMHjhiZT/eDktOPWvSRpqrVE04p0Nd6EGV4/g7MARXXeOqstflqsKuXVM3H9wOIQ==",
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz",
"integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz",
"integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==",
"cpu": [
"loong64"
],
@@ -2562,9 +2553,23 @@
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.48.1.tgz",
"integrity": "sha512-UK0WzWUjMAJccHIeOpPhPcKBqax7QFg47hwZTp6kiMhQHeOYJeaMwzeRZe1q5IiTKsaLnHu9s6toSYVUlZ2QtQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz",
"integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz",
"integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==",
"cpu": [
"ppc64"
],
@@ -2576,9 +2581,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.48.1.tgz",
"integrity": "sha512-3NADEIlt+aCdCbWVZ7D3tBjBX1lHpXxcvrLt/kdXTiBrOds8APTdtk2yRL2GgmnSVeX4YS1JIf0imFujg78vpw==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz",
"integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==",
"cpu": [
"riscv64"
],
@@ -2590,9 +2595,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.48.1.tgz",
"integrity": "sha512-euuwm/QTXAMOcyiFCcrx0/S2jGvFlKJ2Iro8rsmYL53dlblp3LkUQVFzEidHhvIPPvcIsxDhl2wkBE+I6YVGzA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz",
"integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==",
"cpu": [
"riscv64"
],
@@ -2604,9 +2609,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.48.1.tgz",
"integrity": "sha512-w8mULUjmPdWLJgmTYJx/W6Qhln1a+yqvgwmGXcQl2vFBkWsKGUBRbtLRuKJUln8Uaimf07zgJNxOhHOvjSQmBQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz",
"integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==",
"cpu": [
"s390x"
],
@@ -2618,9 +2623,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.48.1.tgz",
"integrity": "sha512-90taWXCWxTbClWuMZD0DKYohY1EovA+W5iytpE89oUPmT5O1HFdf8cuuVIylE6vCbrGdIGv85lVRzTcpTRZ+kA==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz",
"integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==",
"cpu": [
"x64"
],
@@ -2632,9 +2637,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.48.1.tgz",
"integrity": "sha512-2Gu29SkFh1FfTRuN1GR1afMuND2GKzlORQUP3mNMJbqdndOg7gNsa81JnORctazHRokiDzQ5+MLE5XYmZW5VWg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz",
"integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==",
"cpu": [
"x64"
],
@@ -2645,10 +2650,38 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz",
"integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz",
"integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.48.1.tgz",
"integrity": "sha512-6kQFR1WuAO50bxkIlAVeIYsz3RUx+xymwhTo9j94dJ+kmHe9ly7muH23sdfWduD0BA8pD9/yhonUvAjxGh34jQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz",
"integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==",
"cpu": [
"arm64"
],
@@ -2660,9 +2693,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.48.1.tgz",
"integrity": "sha512-RUyZZ/mga88lMI3RlXFs4WQ7n3VyU07sPXmMG7/C1NOi8qisUg57Y7LRarqoGoAiopmGmChUhSwfpvQ3H5iGSQ==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz",
"integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==",
"cpu": [
"ia32"
],
@@ -2673,10 +2706,24 @@
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz",
"integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.48.1.tgz",
"integrity": "sha512-8a/caCUN4vkTChxkaIJcMtwIVcBhi4X2PQRoT+yCK3qRYaZ7cURrmJFL5Ux9H9RaMIXj9RuihckdmkBX3zZsgg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz",
"integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==",
"cpu": [
"x64"
],
@@ -3235,6 +3282,66 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.0.4",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.4.5",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.0.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.0",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.12.tgz",
@@ -3589,9 +3696,9 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3620,6 +3727,16 @@
"node": ">=10"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -3666,6 +3783,19 @@
"readable-stream": "^3.4.0"
}
},
"node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
@@ -5072,9 +5202,9 @@
"license": "MIT"
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.sortby": {
@@ -5267,9 +5397,9 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5290,16 +5420,16 @@
}
},
"node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -5575,9 +5705,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -5956,9 +6086,9 @@
"license": "ISC"
},
"node_modules/rollup": {
"version": "4.48.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.1.tgz",
"integrity": "sha512-jVG20NvbhTYDkGAty2/Yh7HK6/q3DGSRH4o8ALKGArmMuaauM9kLfoMZ+WliPwA5+JHr2lTn3g557FxBV87ifg==",
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5972,26 +6102,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.48.1",
"@rollup/rollup-android-arm64": "4.48.1",
"@rollup/rollup-darwin-arm64": "4.48.1",
"@rollup/rollup-darwin-x64": "4.48.1",
"@rollup/rollup-freebsd-arm64": "4.48.1",
"@rollup/rollup-freebsd-x64": "4.48.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.48.1",
"@rollup/rollup-linux-arm-musleabihf": "4.48.1",
"@rollup/rollup-linux-arm64-gnu": "4.48.1",
"@rollup/rollup-linux-arm64-musl": "4.48.1",
"@rollup/rollup-linux-loongarch64-gnu": "4.48.1",
"@rollup/rollup-linux-ppc64-gnu": "4.48.1",
"@rollup/rollup-linux-riscv64-gnu": "4.48.1",
"@rollup/rollup-linux-riscv64-musl": "4.48.1",
"@rollup/rollup-linux-s390x-gnu": "4.48.1",
"@rollup/rollup-linux-x64-gnu": "4.48.1",
"@rollup/rollup-linux-x64-musl": "4.48.1",
"@rollup/rollup-win32-arm64-msvc": "4.48.1",
"@rollup/rollup-win32-ia32-msvc": "4.48.1",
"@rollup/rollup-win32-x64-msvc": "4.48.1",
"@rollup/rollup-android-arm-eabi": "4.60.1",
"@rollup/rollup-android-arm64": "4.60.1",
"@rollup/rollup-darwin-arm64": "4.60.1",
"@rollup/rollup-darwin-x64": "4.60.1",
"@rollup/rollup-freebsd-arm64": "4.60.1",
"@rollup/rollup-freebsd-x64": "4.60.1",
"@rollup/rollup-linux-arm-gnueabihf": "4.60.1",
"@rollup/rollup-linux-arm-musleabihf": "4.60.1",
"@rollup/rollup-linux-arm64-gnu": "4.60.1",
"@rollup/rollup-linux-arm64-musl": "4.60.1",
"@rollup/rollup-linux-loong64-gnu": "4.60.1",
"@rollup/rollup-linux-loong64-musl": "4.60.1",
"@rollup/rollup-linux-ppc64-gnu": "4.60.1",
"@rollup/rollup-linux-ppc64-musl": "4.60.1",
"@rollup/rollup-linux-riscv64-gnu": "4.60.1",
"@rollup/rollup-linux-riscv64-musl": "4.60.1",
"@rollup/rollup-linux-s390x-gnu": "4.60.1",
"@rollup/rollup-linux-x64-gnu": "4.60.1",
"@rollup/rollup-linux-x64-musl": "4.60.1",
"@rollup/rollup-openbsd-x64": "4.60.1",
"@rollup/rollup-openharmony-arm64": "4.60.1",
"@rollup/rollup-win32-arm64-msvc": "4.60.1",
"@rollup/rollup-win32-ia32-msvc": "4.60.1",
"@rollup/rollup-win32-x64-gnu": "4.60.1",
"@rollup/rollup-win32-x64-msvc": "4.60.1",
"fsevents": "~2.3.2"
}
},
@@ -6290,9 +6425,9 @@
}
},
"node_modules/tar": {
"version": "7.5.7",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz",
"integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -6559,9 +6694,9 @@
"license": "MIT"
},
"node_modules/valibot": {
"version": "0.42.1",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-0.42.1.tgz",
"integrity": "sha512-3keXV29Ar5b//Hqi4MbSdV7lfVp6zuYLZuA9V1PvQUsXqogr+u5lvLPLk3A4f74VUXDnf/JfWMN6sB+koJ/FFw==",
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.3.1.tgz",
"integrity": "sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==",
"license": "MIT",
"peerDependencies": {
"typescript": ">=5"

View File

@@ -1,7 +1,7 @@
{
"name": "beszel",
"private": true,
"version": "0.18.4",
"version": "0.18.7",
"type": "module",
"scripts": {
"dev": "vite --host",
@@ -52,7 +52,7 @@
"recharts": "^2.15.4",
"shiki": "^3.13.0",
"tailwind-merge": "^3.3.1",
"valibot": "^0.42.1"
"valibot": "^1.3.1"
},
"devDependencies": {
"@biomejs/biome": "2.2.4",

View File

@@ -1,8 +1,8 @@
import { msg, t } from "@lingui/core/macro"
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { ChevronDownIcon, ExternalLinkIcon, PlusIcon } from "lucide-react"
import { ChevronDownIcon, ExternalLinkIcon } from "lucide-react"
import { memo, useEffect, useRef, useState } from "react"
import { Button } from "@/components/ui/button"
import {
@@ -12,7 +12,6 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
@@ -35,28 +34,19 @@ import { DropdownMenu, DropdownMenuTrigger } from "./ui/dropdown-menu"
import { AppleIcon, DockerIcon, FreeBsdIcon, TuxIcon, WindowsIcon } from "./ui/icons"
import { InputCopy } from "./ui/input-copy"
export function AddSystemButton({ className }: { className?: string }) {
if (isReadOnlyUser()) {
return null
}
const [open, setOpen] = useState(false)
// To avoid a refactor of the dialog, we will just keep this function as a "skeleton" for the actual dialog
export function AddSystemDialog({ open, setOpen }: { open: boolean; setOpen: (open: boolean) => void }) {
const opened = useRef(false)
if (open) {
opened.current = true
}
if (isReadOnlyUser()) {
return null
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline" className={cn("flex gap-1 max-xs:h-[2.4rem]", className)}>
<PlusIcon className="h-4 w-4 450:-ms-1" />
<span className="hidden 450:inline">
<Trans>
Add <span className="hidden sm:inline">System</span>
</Trans>
</span>
</Button>
</DialogTrigger>
{opened.current && <SystemDialog setOpen={setOpen} />}
</Dialog>
)
@@ -276,7 +266,13 @@ export const SystemDialog = ({ setOpen, system }: { setOpen: (open: boolean) =>
/>
</TabsContent>
{/* Save */}
<Button>{system ? <Trans>Save system</Trans> : <Trans>Add system</Trans>}</Button>
<Button>
{system ? (
<Trans>Save {{ foo: systemTranslation }}</Trans>
) : (
<Trans>Add {{ foo: systemTranslation }}</Trans>
)}
</Button>
</DialogFooter>
</form>
</Tabs>

View File

@@ -20,7 +20,7 @@ export default memo(function AlertsButton({ system }: { system: SystemRecord })
<SheetTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t`Alerts`} data-nolink onClick={() => setOpened(true)}>
<BellIcon
className={cn("h-[1.2em] w-[1.2em] pointer-events-none", {
className={cn("size-[1.2em] pointer-events-none", {
"fill-primary": hasSystemAlert,
})}
/>

View File

@@ -2,11 +2,13 @@ import { t } from "@lingui/core/macro"
import { Plural, Trans } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { GlobeIcon, ServerIcon } from "lucide-react"
import { ChevronDownIcon, GlobeIcon, ServerIcon } from "lucide-react"
import { lazy, memo, Suspense, useMemo, useState } from "react"
import { $router, Link } from "@/components/router"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { Switch } from "@/components/ui/switch"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
@@ -64,11 +66,57 @@ const deleteAlerts = debounce(async ({ name, systems }: { name: string; systems:
export const AlertDialogContent = memo(function AlertDialogContent({ system }: { system: SystemRecord }) {
const alerts = useStore($alerts)
const systems = useStore($systems)
const [overwriteExisting, setOverwriteExisting] = useState<boolean | "indeterminate">(false)
const [currentTab, setCurrentTab] = useState("system")
// copyKey is used to force remount AlertContent components with
// new alert data after copying alerts from another system
const [copyKey, setCopyKey] = useState(0)
const systemAlerts = alerts[system.id] ?? new Map()
// Systems that have at least one alert configured (excluding the current system)
const systemsWithAlerts = useMemo(
() => systems.filter((s) => s.id !== system.id && alerts[s.id]?.size),
[systems, alerts, system.id]
)
async function copyAlertsFromSystem(sourceSystemId: string) {
const sourceAlerts = $alerts.get()[sourceSystemId]
if (!sourceAlerts?.size) return
try {
const currentTargetAlerts = $alerts.get()[system.id] ?? new Map()
// Alert names present on target but absent from source should be deleted
const namesToDelete = Array.from(currentTargetAlerts.keys()).filter((name) => !sourceAlerts.has(name))
await Promise.all([
...Array.from(sourceAlerts.values()).map(({ name, value, min }) =>
pb.send<{ success: boolean }>(endpoint, {
method: "POST",
body: { name, value, min, systems: [system.id], overwrite: true },
requestKey: name,
})
),
...namesToDelete.map((name) =>
pb.send<{ success: boolean }>(endpoint, {
method: "DELETE",
body: { name, systems: [system.id] },
requestKey: name,
})
),
])
// Optimistically update the store so components re-mount with correct data
// before the realtime subscription event arrives.
const newSystemAlerts = new Map<string, AlertRecord>()
for (const alert of sourceAlerts.values()) {
newSystemAlerts.set(alert.name, { ...alert, system: system.id, triggered: false })
}
$alerts.setKey(system.id, newSystemAlerts)
setCopyKey((k) => k + 1)
} catch (error) {
failedUpdateToast(error)
}
}
// We need to keep a copy of alerts when we switch to global tab. If we always compare to
// current alerts, it will only be updated when first checked, then won't be updated because
// after that it exists.
@@ -93,18 +141,37 @@ export const AlertDialogContent = memo(function AlertDialogContent({ system }: {
</DialogDescription>
</DialogHeader>
<Tabs defaultValue="system" onValueChange={setCurrentTab}>
<TabsList className="mb-1 -mt-0.5">
<TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" />
<span className="truncate max-w-60">{system.name}</span>
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<Trans>All Systems</Trans>
</TabsTrigger>
</TabsList>
<div className="flex items-center justify-between mb-1 -mt-0.5">
<TabsList>
<TabsTrigger value="system">
<ServerIcon className="me-2 h-3.5 w-3.5" />
<span className="truncate max-w-60">{system.name}</span>
</TabsTrigger>
<TabsTrigger value="global">
<GlobeIcon className="me-1.5 h-3.5 w-3.5" />
<Trans>All Systems</Trans>
</TabsTrigger>
</TabsList>
{systemsWithAlerts.length > 0 && currentTab === "system" && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="text-muted-foreground text-xs gap-1.5">
<Trans context="Copy alerts from another system">Copy from</Trans>
<ChevronDownIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-100 overflow-auto">
{systemsWithAlerts.map((s) => (
<DropdownMenuItem key={s.id} className="min-w-44" onSelect={() => copyAlertsFromSystem(s.id)}>
{s.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<TabsContent value="system">
<div className="grid gap-3">
<div key={copyKey} className="grid gap-3">
{alertKeys.map((name) => (
<AlertContent
key={name}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import {
ChartContainer,
@@ -11,18 +11,23 @@ import {
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
import { AxisDomain } from "recharts/types/util/types"
import type { AxisDomain } from "recharts/types/util/types"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
export type DataPoint = {
export type DataPoint<T = SystemStatsRecord> = {
label: string
dataKey: (data: SystemStatsRecord) => number | undefined
dataKey: (data: T) => number | null | undefined
color: number | string
opacity: number
stackId?: string | number
order?: number
strokeOpacity?: number
activeDot?: boolean
}
export default function AreaChartDefault({
chartData,
customData,
max,
maxToggled,
tickFormatter,
@@ -34,96 +39,133 @@ export default function AreaChartDefault({
showTotal = false,
reverseStackOrder = false,
hideYAxis = false,
}: // logRender = false,
{
chartData: ChartData
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: AxisDomain
legend?: boolean
showTotal?: boolean
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
// logRender?: boolean
}) {
filter,
truncate = false,
chartProps,
}: {
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
customData?: any[]
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
contentFormatter: (item: any, key: string) => ReactNode
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
dataPoints?: DataPoint<any>[]
domain?: AxisDomain
legend?: boolean
showTotal?: boolean
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
itemSorter?: (a: any, b: any) => number
reverseStackOrder?: boolean
hideYAxis?: boolean
filter?: string
truncate?: boolean
chartProps?: Omit<React.ComponentProps<typeof AreaChart>, "data" | "margin">
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
// Reduce chart redraws by only updating while visible or when chart time changes
useEffect(() => {
const shouldPrimeData = sourceData.length && !displayData.length
const sourceChanged = sourceData !== displayData
const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting)
if (shouldUpdate) {
setDisplayData(sourceData)
}
if (isIntersecting && maxToggled !== displayMaxToggled) {
setDisplayMaxToggled(maxToggled)
}
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
// Use a stable key derived from data point identities and visual properties
const areasKey = dataPoints?.map((d) => `${d.label}:${d.opacity}`).join("\0")
const Areas = useMemo(() => {
return dataPoints?.map((dataPoint, i) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Area
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
fill={color}
fillOpacity={dataPoint.opacity}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
isAnimationActive={false}
stackId={dataPoint.stackId}
order={dataPoint.order || i}
activeDot={dataPoint.activeDot ?? true}
/>
)
})
}, [areasKey, displayMaxToggled])
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
return useMemo(() => {
if (chartData.systemStats.length === 0) {
if (displayData.length === 0) {
return null
}
// if (logRender) {
// console.log("Rendered at", new Date())
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
// }
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
<ChartContainer
ref={ref}
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
>
<AreaChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={displayData}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
{...chartProps}
>
<AreaChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={chartData.systemStats}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
>
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
)}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
/>
}
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={domain ?? [0, max ?? "auto"]}
tickFormatter={(value, index) => updateYAxisWidth(tickFormatter(value, index))}
tickLine={false}
axisLine={false}
/>
{dataPoints?.map((dataPoint) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Area
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
fill={color}
fillOpacity={dataPoint.opacity}
stroke={color}
isAnimationActive={false}
stackId={dataPoint.stackId}
/>
)
})}
{legend && <ChartLegend content={<ChartLegendContent reverse={reverseStackOrder} />} />}
</AreaChart>
</ChartContainer>
</div>
)}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
filter={filter}
truncate={truncate}
/>
}
/>
{Areas}
{legend && <ChartLegend content={<ChartLegendContent />} />}
</AreaChart>
</ChartContainer>
)
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled, showTotal])
}, [displayData, yAxisWidth, filter, Areas])
}

View File

@@ -1,215 +0,0 @@
// import Spinner from '../spinner'
import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import {
type ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
pinnedAxisDomain,
xAxis,
} from "@/components/ui/chart"
import { ChartType, Unit } from "@/lib/enums"
import { $containerFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { Separator } from "../ui/separator"
import { useYAxisWidth } from "./hooks"
export default memo(function ContainerChart({
dataKey,
chartData,
chartType,
chartConfig,
unit = "%",
}: {
dataKey: string
chartData: ChartData
chartType: ChartType
chartConfig: ChartConfig
unit?: string
}) {
const filter = useStore($containerFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { containerData } = chartData
const isNetChart = chartType === ChartType.Network
// Filter with set lookup
const filteredKeys = useMemo(() => {
if (!filter) {
return new Set<string>()
}
const filterTerms = filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
return new Set(
Object.keys(chartConfig).filter((key) => {
const keyLower = key.toLowerCase()
return !filterTerms.some((term) => keyLower.includes(term))
})
)
}, [chartConfig, filter])
// biome-ignore lint/correctness/useExhaustiveDependencies: not necessary
const { toolTipFormatter, dataFunction, tickFormatter } = useMemo(() => {
const obj = {} as {
toolTipFormatter: (item: any, key: string) => React.ReactNode | string
dataFunction: (key: string, data: any) => number | null
tickFormatter: (value: any) => string
}
// tick formatter
if (chartType === ChartType.CPU) {
obj.tickFormatter = (value) => {
const val = `${toFixedFloat(value, 2)}%`
return updateYAxisWidth(val)
}
} else {
const chartUnit = isNetChart ? userSettings.unitNet : Unit.Bytes
obj.tickFormatter = (val) => {
const { value, unit } = formatBytes(val, isNetChart, chartUnit, !isNetChart)
return updateYAxisWidth(`${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`)
}
}
// tooltip formatter
if (isNetChart) {
const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => {
if (record?.b?.length && record.b.length >= 2) {
return [Number(record.b[0]) || 0, Number(record.b[1]) || 0]
}
return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024]
}
const formatRxTx = (recv: number, sent: number) => {
const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false)
return (
<span className="flex">
{decimalString(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
}
obj.toolTipFormatter = (item: any, key: string) => {
try {
if (key === "__total__") {
let totalSent = 0
let totalRecv = 0
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
for (const [containerKey, value] of Object.entries(payloadData)) {
if (!value || typeof value !== "object") {
continue
}
// Skip filtered out containers
if (filteredKeys.has(containerKey)) {
continue
}
const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
totalSent += sent
totalRecv += recv
}
return formatRxTx(totalRecv, totalSent)
}
const [sent, recv] = getRxTxBytes(item?.payload?.[key])
return formatRxTx(recv, sent)
} catch (e) {
return null
}
}
} else if (chartType === ChartType.Memory) {
obj.toolTipFormatter = (item: any) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return `${decimalString(value)} ${unit}`
}
} else {
obj.toolTipFormatter = (item: any) => `${decimalString(item.value)}${unit}`
}
// data function
if (isNetChart) {
obj.dataFunction = (key: string, data: any) => {
const payload = data[key]
if (!payload) {
return null
}
const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024
const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024
return sent + recv
}
} else {
obj.dataFunction = (key: string, data: any) => data[key]?.[dataKey] ?? null
}
return obj
}, [filteredKeys])
// console.log('rendered at', new Date())
if (containerData.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart
accessibilityLayer
// syncId={'cpu'}
data={containerData}
margin={chartMargin}
reverseStackOrder={true}
>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
domain={pinnedAxisDomain()}
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
tickFormatter={tickFormatter}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
truncate={true}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={<ChartTooltipContent filter={filter} contentFormatter={toolTipFormatter} showTotal={true} />}
/>
{Object.keys(chartConfig).map((key) => {
const filtered = filteredKeys.has(key)
const fillOpacity = filtered ? 0.05 : 0.4
const strokeOpacity = filtered ? 0.1 : 1
return (
<Area
key={key}
isAnimationActive={false}
dataKey={dataFunction.bind(null, key)}
name={key}
type="monotoneX"
fill={chartConfig[key].color}
fillOpacity={fillOpacity}
stroke={chartConfig[key].color}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
stackId="a"
/>
)
})}
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,83 +0,0 @@
import { useLingui } from "@lingui/react/macro"
import { memo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { Unit } from "@/lib/enums"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function DiskChart({
dataKey,
diskSize,
chartData,
}: {
dataKey: string | ((data: SystemStatsRecord) => number | undefined)
diskSize: number
chartData: ChartData
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { t } = useLingui()
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
width={yAxisWidth}
domain={[0, diskSize]}
tickCount={9}
minTickGap={6}
tickLine={false}
axisLine={false}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(value, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue) + " " + unit
}}
/>
}
/>
<Area
dataKey={dataKey}
name={t`Disk Usage`}
type="monotoneX"
fill="var(--chart-4)"
fillOpacity={0.4}
stroke="var(--chart-4)"
// animationDuration={1200}
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,117 +0,0 @@
import { memo, useMemo } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData, GPUData } from "@/types"
import { useYAxisWidth } from "./hooks"
import type { DataPoint } from "./line-chart"
export default memo(function GpuPowerChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const packageKey = " package"
const { gpuData, dataPoints } = useMemo(() => {
const dataPoints = [] as DataPoint[]
const gpuData = [] as Record<string, GPUData | string>[]
const addedKeys = new Map<string, number>()
const addKey = (key: string, value: number) => {
addedKeys.set(key, (addedKeys.get(key) ?? 0) + value)
}
for (const stats of chartData.systemStats) {
const gpus = stats.stats?.g ?? {}
const data = { created: stats.created } as Record<string, GPUData | string>
for (const id in gpus) {
const gpu = gpus[id] as GPUData
data[gpu.n] = gpu
addKey(gpu.n, gpu.p ?? 0)
if (gpu.pp) {
data[`${gpu.n}${packageKey}`] = gpu
addKey(`${gpu.n}${packageKey}`, gpu.pp ?? 0)
}
}
gpuData.push(data)
}
const sortedKeys = Array.from(addedKeys.entries())
.sort(([, a], [, b]) => b - a)
.map(([key]) => key)
for (let i = 0; i < sortedKeys.length; i++) {
const id = sortedKeys[i]
dataPoints.push({
label: id,
dataKey: (gpuData: Record<string, GPUData>) => {
return id.endsWith(packageKey) ? (gpuData[id]?.pp ?? 0) : (gpuData[id]?.p ?? 0)
},
color: `hsl(${226 + (((i * 360) / addedKeys.size) % 360)}, 65%, 52%)`,
})
}
return { gpuData, dataPoints }
}, [chartData])
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={gpuData} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
const val = toFixedFloat(value, 2)
return updateYAxisWidth(`${val}W`)
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => `${decimalString(item.value)}W`}
// indicator="line"
/>
}
/>
{dataPoints.map((dataPoint) => (
<Line
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={dataPoint.color as string}
isAnimationActive={false}
/>
))}
{dataPoints.length > 1 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,6 +1,9 @@
import { useMemo, useState } from "react"
import { useStore } from "@nanostores/react"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData, SystemStats, SystemStatsRecord } from "@/types"
import type { DataPoint } from "./area-chart"
import { $containerFilter } from "@/lib/stores"
/** Chart configurations for CPU, memory, and network usage charts */
export interface ContainerChartConfigs {
@@ -96,9 +99,9 @@ export function useYAxisWidth() {
clearTimeout(timeout)
timeout = setTimeout(() => {
document.body.appendChild(div)
const width = div.offsetWidth + 24
const width = div.offsetWidth + 20
if (width > yAxisWidth) {
setYAxisWidth(div.offsetWidth + 24)
setYAxisWidth(width)
}
document.body.removeChild(div)
})
@@ -108,6 +111,44 @@ export function useYAxisWidth() {
return { yAxisWidth, updateYAxisWidth }
}
/** Subscribes to the container filter store and returns filtered DataPoints for container charts */
export function useContainerDataPoints(
chartConfig: ChartConfig,
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
dataFn: (key: string, data: Record<string, any>) => number | null
) {
const filter = useStore($containerFilter)
const { dataPoints, filteredKeys } = useMemo(() => {
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = new Set<string>()
const points = Object.keys(chartConfig).map((key) => {
const isFiltered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
if (isFiltered) filtered.add(key)
return {
label: key,
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
dataKey: (data: Record<string, any>) => dataFn(key, data),
color: chartConfig[key].color ?? "",
opacity: isFiltered ? 0.05 : 0.4,
strokeOpacity: isFiltered ? 0.1 : 1,
activeDot: !isFiltered,
stackId: "a",
}
})
return {
// biome-ignore lint/suspicious/noExplicitAny: container data records have dynamic keys
dataPoints: points as DataPoint<Record<string, any>>[],
filteredKeys: filtered,
}
}, [chartConfig, filter])
return { filter, dataPoints, filteredKeys }
}
// Assures consistent colors for network interfaces
export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
const keys = Object.keys(interfaces ?? {})
@@ -124,4 +165,4 @@ export function useNetworkInterfaces(interfaces: SystemStats["ni"]) {
}))
},
}
}
}

View File

@@ -1,4 +1,4 @@
import { useMemo } from "react"
import { type ReactNode, useEffect, useMemo, useState } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
@@ -11,15 +11,22 @@ import {
import { chartMargin, cn, formatShortDate } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { useYAxisWidth } from "./hooks"
import type { AxisDomain } from "recharts/types/util/types"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
export type DataPoint = {
export type DataPoint<T = SystemStatsRecord> = {
label: string
dataKey: (data: SystemStatsRecord) => number | undefined
dataKey: (data: T) => number | null | undefined
color: number | string
stackId?: string | number
order?: number
strokeOpacity?: number
activeDot?: boolean
}
export default function LineChartDefault({
chartData,
customData,
max,
maxToggled,
tickFormatter,
@@ -28,38 +35,105 @@ export default function LineChartDefault({
domain,
legend,
itemSorter,
}: // logRender = false,
{
showTotal = false,
reverseStackOrder = false,
hideYAxis = false,
filter,
truncate = false,
chartProps,
}: {
chartData: ChartData
// biome-ignore lint/suspicious/noExplicitAny: accepts different data source types (systemStats or containerData)
customData?: any[]
max?: number
maxToggled?: boolean
tickFormatter: (value: number, index: number) => string
contentFormatter: ({ value, payload }: { value: number; payload: SystemStatsRecord }) => string
dataPoints?: DataPoint[]
domain?: [number, number]
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
contentFormatter: (item: any, key: string) => ReactNode
// biome-ignore lint/suspicious/noExplicitAny: accepts DataPoint with different generic types
dataPoints?: DataPoint<any>[]
domain?: AxisDomain
legend?: boolean
showTotal?: boolean
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item interop
itemSorter?: (a: any, b: any) => number
// logRender?: boolean
reverseStackOrder?: boolean
hideYAxis?: boolean
filter?: string
truncate?: boolean
chartProps?: Omit<React.ComponentProps<typeof LineChart>, "data" | "margin">
}) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { isIntersecting, ref } = useIntersectionObserver({ freeze: false })
const sourceData = customData ?? chartData.systemStats
const [displayData, setDisplayData] = useState(sourceData)
const [displayMaxToggled, setDisplayMaxToggled] = useState(maxToggled)
// Reduce chart redraws by only updating while visible or when chart time changes
useEffect(() => {
const shouldPrimeData = sourceData.length && !displayData.length
const sourceChanged = sourceData !== displayData
const shouldUpdate = shouldPrimeData || (sourceChanged && isIntersecting)
if (shouldUpdate) {
setDisplayData(sourceData)
}
if (isIntersecting && maxToggled !== displayMaxToggled) {
setDisplayMaxToggled(maxToggled)
}
}, [displayData, displayMaxToggled, isIntersecting, maxToggled, sourceData])
// Use a stable key derived from data point identities and visual properties
const linesKey = dataPoints?.map((d) => `${d.label}:${d.strokeOpacity ?? ""}`).join("\0")
const Lines = useMemo(() => {
return dataPoints?.map((dataPoint, i) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Line
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
strokeOpacity={dataPoint.strokeOpacity}
isAnimationActive={false}
// stackId={dataPoint.stackId}
order={dataPoint.order || i}
// activeDot={dataPoint.activeDot ?? true}
/>
)
})
}, [linesKey, displayMaxToggled])
// biome-ignore lint/correctness/useExhaustiveDependencies: ignore
return useMemo(() => {
if (chartData.systemStats.length === 0) {
if (displayData.length === 0) {
return null
}
// if (logRender) {
// console.log("Rendered at", new Date())
// console.log("Rendered", dataPoints?.map((d) => d.label).join(", "), new Date())
// }
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
<ChartContainer
ref={ref}
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth || hideYAxis,
"ps-4": hideYAxis,
})}
>
<LineChart
reverseStackOrder={reverseStackOrder}
accessibilityLayer
data={displayData}
margin={hideYAxis ? { ...chartMargin, left: 5 } : chartMargin}
{...chartProps}
>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<CartesianGrid vertical={false} />
{!hideYAxis && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
@@ -70,41 +144,27 @@ export default function LineChartDefault({
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
/>
}
/>
{dataPoints?.map((dataPoint) => {
let { color } = dataPoint
if (typeof color === "number") {
color = `var(--chart-${color})`
}
return (
<Line
key={dataPoint.label}
dataKey={dataPoint.dataKey}
name={dataPoint.label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
)
})}
{legend && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)}
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={itemSorter}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={contentFormatter}
showTotal={showTotal}
filter={filter}
truncate={truncate}
/>
}
/>
{Lines}
{legend && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
)
}, [chartData.systemStats.at(-1), yAxisWidth, maxToggled])
}, [displayData, yAxisWidth, filter, Lines])
}

View File

@@ -1,83 +0,0 @@
import { t } from "@lingui/core/macro"
import { memo } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { chartMargin, cn, decimalString, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStats } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function LoadAverageChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const keys: { color: string; label: string }[] = [
{
color: "hsl(271, 81%, 60%)", // Purple
label: t({ message: `1 min`, comment: "Load average" }),
},
{
color: "hsl(217, 91%, 60%)", // Blue
label: t({ message: `5 min`, comment: "Load average" }),
},
{
color: "hsl(25, 95%, 53%)", // Orange
label: t({ message: `15 min`, comment: "Load average" }),
},
]
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, "auto"]}
width={yAxisWidth}
tickFormatter={(value) => {
return updateYAxisWidth(String(toFixedFloat(value, 2)))
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => decimalString(item.value)}
/>
}
/>
{keys.map(({ color, label }, i) => (
<Line
key={label}
dataKey={(value: { stats: SystemStats }) => value.stats?.la?.[i]}
name={label}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={color}
isAnimationActive={false}
/>
))}
<ChartLegend content={<ChartLegendContent />} />
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,108 +0,0 @@
import { useLingui } from "@lingui/react/macro"
import { memo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { Unit } from "@/lib/enums"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function MemChart({ chartData, showMax }: { chartData: ChartData; showMax: boolean }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const { t } = useLingui()
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
// console.log('rendered at', new Date())
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
{/* {!yAxisSet && <Spinner />} */}
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
{totalMem && (
<YAxis
direction="ltr"
orientation={chartData.orientation}
// use "ticks" instead of domain / tickcount if need more control
domain={[0, totalMem]}
tickCount={9}
className="tracking-tighter"
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
)}
{xAxis(chartData)}
<ChartTooltip
// cursor={false}
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
// @ts-expect-error
itemSorter={(a, b) => a.order - b.order}
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
showTotal={true}
/>
}
/>
<Area
name={t`Used`}
order={3}
dataKey={({ stats }) => (showMax ? stats?.mm : stats?.mu)}
type="monotoneX"
fill="var(--chart-2)"
fillOpacity={0.4}
stroke="var(--chart-2)"
stackId="1"
isAnimationActive={false}
/>
{/* {chartData.systemStats.at(-1)?.stats.mz && ( */}
<Area
name="ZFS ARC"
order={2}
dataKey={({ stats }) => (showMax ? null : stats?.mz)}
type="monotoneX"
fill="hsla(175 60% 45% / 0.8)"
fillOpacity={0.5}
stroke="hsla(175 60% 45% / 0.8)"
stackId="1"
isAnimationActive={false}
/>
{/* )} */}
<Area
name={t`Cache / Buffers`}
order={1}
dataKey={({ stats }) => (showMax ? null : stats?.mb)}
type="monotoneX"
fill="hsla(160 60% 45% / 0.5)"
fillOpacity={0.4}
stroke="hsla(160 60% 45% / 0.5)"
stackId="1"
isAnimationActive={false}
/>
{/* <ChartLegend content={<ChartLegendContent />} /> */}
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,70 +0,0 @@
import { t } from "@lingui/core/macro"
import { useStore } from "@nanostores/react"
import { memo } from "react"
import { Area, AreaChart, CartesianGrid, YAxis } from "recharts"
import { ChartContainer, ChartTooltip, ChartTooltipContent, xAxis } from "@/components/ui/chart"
import { $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatBytes, formatShortDate, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function SwapChart({ chartData }: { chartData: ChartData }) {
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
const userSettings = useStore($userSettings)
if (chartData.systemStats.length === 0) {
return null
}
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<AreaChart accessibilityLayer data={chartData.systemStats} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
width={yAxisWidth}
tickLine={false}
axisLine={false}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
return updateYAxisWidth(toFixedFloat(convertedValue, value >= 10 ? 0 : 1) + " " + unit)
}}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, userSettings.unitDisk, true)
return decimalString(convertedValue, convertedValue >= 100 ? 1 : 2) + " " + unit
}}
// indicator="line"
/>
}
/>
<Area
dataKey="stats.su"
name={t`Used`}
type="monotoneX"
fill="var(--chart-2)"
fillOpacity={0.4}
stroke="var(--chart-2)"
isAnimationActive={false}
/>
</AreaChart>
</ChartContainer>
</div>
)
})

View File

@@ -1,117 +0,0 @@
import { useStore } from "@nanostores/react"
import { memo, useMemo } from "react"
import { CartesianGrid, Line, LineChart, YAxis } from "recharts"
import {
ChartContainer,
ChartLegend,
ChartLegendContent,
ChartTooltip,
ChartTooltipContent,
xAxis,
} from "@/components/ui/chart"
import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { chartMargin, cn, decimalString, formatShortDate, formatTemperature, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { useYAxisWidth } from "./hooks"
export default memo(function TemperatureChart({ chartData }: { chartData: ChartData }) {
const filter = useStore($temperatureFilter)
const userSettings = useStore($userSettings)
const { yAxisWidth, updateYAxisWidth } = useYAxisWidth()
if (chartData.systemStats.length === 0) {
return null
}
/** Format temperature data for chart and assign colors */
const newChartData = useMemo(() => {
const newChartData = { data: [], colors: {} } as {
data: Record<string, number | string>[]
colors: Record<string, string>
}
const tempSums = {} as Record<string, number>
for (const data of chartData.systemStats) {
const newData = { created: data.created } as Record<string, number | string>
const keys = Object.keys(data.stats?.t ?? {})
for (let i = 0; i < keys.length; i++) {
const key = keys[i]
newData[key] = data.stats.t![key]
tempSums[key] = (tempSums[key] ?? 0) + newData[key]
}
newChartData.data.push(newData)
}
const keys = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
for (const key of keys) {
newChartData.colors[key] = `hsl(${((keys.indexOf(key) * 360) / keys.length) % 360}, 60%, 55%)`
}
return newChartData
}, [chartData])
const colors = Object.keys(newChartData.colors)
// console.log('rendered at', new Date())
return (
<div>
<ChartContainer
className={cn("h-full w-full absolute aspect-auto bg-card opacity-0 transition-opacity", {
"opacity-100": yAxisWidth,
})}
>
<LineChart accessibilityLayer data={newChartData.data} margin={chartMargin}>
<CartesianGrid vertical={false} />
<YAxis
direction="ltr"
orientation={chartData.orientation}
className="tracking-tighter"
domain={["auto", "auto"]}
width={yAxisWidth}
tickFormatter={(val) => {
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return updateYAxisWidth(toFixedFloat(value, 2) + " " + unit)
}}
tickLine={false}
axisLine={false}
/>
{xAxis(chartData)}
<ChartTooltip
animationEasing="ease-out"
animationDuration={150}
// @ts-expect-error
itemSorter={(a, b) => b.value - a.value}
content={
<ChartTooltipContent
labelFormatter={(_, data) => formatShortDate(data[0].payload.created)}
contentFormatter={(item) => {
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
return decimalString(value) + " " + unit
}}
filter={filter}
/>
}
/>
{colors.map((key) => {
const filterTerms = filter ? filter.toLowerCase().split(" ").filter(term => term.length > 0) : []
const filtered = filterTerms.length > 0 && !filterTerms.some(term => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return (
<Line
key={key}
dataKey={key}
name={key}
type="monotoneX"
dot={false}
strokeWidth={1.5}
stroke={newChartData.colors[key]}
strokeOpacity={strokeOpacity}
activeDot={{ opacity: filtered ? 0 : 1 }}
isAnimationActive={false}
/>
)
})}
{colors.length < 12 && <ChartLegend content={<ChartLegendContent />} />}
</LineChart>
</ChartContainer>
</div>
)
})

View File

@@ -163,9 +163,9 @@ export default function ContainersTable({ systemId }: { systemId?: string }) {
const visibleColumns = table.getVisibleLeafColumns()
return (
<Card className="p-6 @container w-full">
<CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end">
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Containers</Trans>
@@ -462,7 +462,6 @@ function ContainerSheet({
function ContainersTableHead({ table }: { table: TableType<ContainerRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@@ -1,7 +1,11 @@
import { useStore } from "@nanostores/react"
import { GithubIcon } from "lucide-react"
import { $newVersion } from "@/lib/stores"
import { Separator } from "./ui/separator"
import { Trans } from "@lingui/react/macro"
export function FooterRepoLink() {
const newVersion = useStore($newVersion)
return (
<div className="flex gap-1.5 justify-end items-center pe-3 sm:pe-6 mt-3.5 mb-4 text-xs opacity-80">
<a
@@ -21,6 +25,19 @@ export function FooterRepoLink() {
>
Beszel {globalThis.BESZEL.HUB_VERSION}
</a>
{newVersion?.v && (
<>
<Separator orientation="vertical" className="h-2.5 bg-muted-foreground opacity-70" />
<a
href={newVersion.url}
target="_blank"
className="text-yellow-500 hover:text-yellow-400 duration-75"
rel="noopener"
>
<Trans context="New version available">{newVersion.v} available</Trans>
</a>
</>
)}
</div>
)
}

View File

@@ -1,6 +1,6 @@
import { Trans, useLingui } from "@lingui/react/macro"
import { LanguagesIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { buttonVariants } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { dynamicActivate } from "@/lib/i18n"
import languages from "@/lib/languages"
@@ -14,31 +14,29 @@ export function LangToggle() {
return (
<DropdownMenu>
<DropdownMenuTrigger>
<Tooltip>
<TooltipTrigger asChild>
<Button variant={"ghost"} size="icon" className="hidden sm:flex">
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
<span className="sr-only">{LangTrans}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{LangTrans}</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent className="grid grid-cols-3">
{languages.map(([lang, label, e]) => (
<DropdownMenuItem
key={lang}
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
onClick={() => dynamicActivate(lang)}
>
<span>
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger className={cn(buttonVariants({ variant: "ghost", size: "icon" }))}>
<LanguagesIcon className="absolute h-[1.2rem] w-[1.2rem] light:opacity-85" />
<span className="sr-only">{LangTrans}</span>
<TooltipContent>{LangTrans}</TooltipContent>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent className="grid grid-cols-3">
{languages.map(([lang, label, e]) => (
<DropdownMenuItem
key={lang}
className={cn("px-2.5 flex gap-2.5 cursor-pointer", lang === i18n.locale && "bg-accent/70 font-medium")}
onClick={() => dynamicActivate(lang)}
>
<span>
{e || <code className="font-mono bg-muted text-[.65em] w-5 h-4 grid place-items-center">{lang}</code>}
</span>{" "}
{label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Tooltip>
</DropdownMenu>
)
}

View File

@@ -10,7 +10,7 @@ export function ModeToggle() {
return (
<Tooltip>
<TooltipTrigger>
<TooltipTrigger asChild>
<Button
variant={"ghost"}
size="icon"

View File

@@ -1,3 +1,4 @@
import { t } from "@lingui/core/macro"
import { Trans } from "@lingui/react/macro"
import { getPagePath } from "@nanostores/router"
import {
@@ -6,6 +7,8 @@ import {
HardDriveIcon,
LogOutIcon,
LogsIcon,
MenuIcon,
PlusIcon,
SearchIcon,
ServerIcon,
SettingsIcon,
@@ -21,15 +24,18 @@ import {
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { isAdmin, isReadOnlyUser, logOut, pb } from "@/lib/api"
import { cn, runOnce } from "@/lib/utils"
import { AddSystemButton } from "./add-system"
import { AddSystemDialog } from "./add-system"
import { LangToggle } from "./lang-toggle"
import { Logo } from "./logo"
import { ModeToggle } from "./mode-toggle"
import { $router, basePath, Link, prependBasePath } from "./router"
import { $router, basePath, Link, navigate, prependBasePath } from "./router"
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"
const CommandPalette = lazy(() => import("./command-palette"))
@@ -37,20 +43,117 @@ const CommandPalette = lazy(() => import("./command-palette"))
const isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0
export default function Navbar() {
const [addSystemDialogOpen, setAddSystemDialogOpen] = useState(false)
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const AdminLinks = AdminDropdownGroup()
const systemTranslation = t`System`
return (
<div className="flex items-center h-14 md:h-16 bg-card px-4 pe-3 sm:px-6 border border-border/60 bt-0 rounded-md my-4">
<Suspense>
<CommandPalette open={commandPaletteOpen} setOpen={setCommandPaletteOpen} />
</Suspense>
<AddSystemDialog open={addSystemDialogOpen} setOpen={setAddSystemDialogOpen} />
<Link
href={basePath}
aria-label="Home"
className="p-2 ps-0 me-3 group"
onMouseEnter={runOnce(() => import("@/components/routes/home"))}
>
<Logo className="h-[1.1rem] md:h-5 fill-foreground" />
<Logo className="h-[1.2rem] md:h-5 fill-foreground" />
</Link>
<SearchButton />
<Button
variant="outline"
className="hidden md:block text-sm text-muted-foreground px-4"
onClick={() => setCommandPaletteOpen(true)}
>
<span className="flex items-center">
<SearchIcon className="me-1.5 h-4 w-4" />
<Trans>Search</Trans>
<span className="flex items-center ms-3.5">
<Kbd>{isMac ? "⌘" : "Ctrl"}</Kbd>
<Kbd>K</Kbd>
</span>
</span>
</Button>
{/* mobile menu */}
<div className="ms-auto flex items-center text-xl md:hidden">
<ModeToggle />
<Button variant="ghost" size="icon" onClick={() => setCommandPaletteOpen(true)}>
<SearchIcon className="h-[1.2rem] w-[1.2rem]" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger
onMouseEnter={() => import("@/components/routes/settings/general")}
className="ms-3"
aria-label="Open Menu"
>
<MenuIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel className="max-w-40 truncate">{pb.authStore.record?.email}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "containers"))}
className="flex items-center"
>
<ContainerIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<Trans>All Containers</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => navigate(getPagePath($router, "smart"))} className="flex items-center">
<HardDriveIcon className="h-4 w-4 me-2.5" strokeWidth={1.5} />
<span>S.M.A.R.T.</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => navigate(getPagePath($router, "settings", { name: "general" }))}
className="flex items-center"
>
<SettingsIcon className="h-4 w-4 me-2.5" />
<Trans>Settings</Trans>
</DropdownMenuItem>
{isAdmin() && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>
<UserIcon className="h-4 w-4 me-2.5" />
<Trans>Admin</Trans>
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>{AdminLinks}</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{!isReadOnlyUser() && (
<DropdownMenuItem
className="flex items-center"
onSelect={() => {
setAddSystemDialogOpen(true)
}}
>
<PlusIcon className="h-4 w-4 me-2.5" />
<Trans>Add {{ foo: systemTranslation }}</Trans>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem onSelect={logOut} className="flex items-center">
<LogOutIcon className="h-4 w-4 me-2.5" />
<Trans>Log Out</Trans>
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* desktop nav */}
{/** biome-ignore lint/a11y/noStaticElementInteractions: ignore */}
<div className="flex items-center ms-auto" onMouseEnter={() => import("@/components/routes/settings/general")}>
<div
className="hidden md:flex items-center ms-auto"
onMouseEnter={() => import("@/components/routes/settings/general")}
>
<Tooltip>
<TooltipTrigger asChild>
<Link
@@ -102,45 +205,12 @@ export default function Navbar() {
<DropdownMenuContent align={isReadOnlyUser() ? "end" : "center"} className="min-w-44">
<DropdownMenuLabel>{pb.authStore.record?.email}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{isAdmin() && (
<>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/")} target="_blank">
<UsersIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Users</Trans>
</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/#/collections?collection=systems")} target="_blank">
<ServerIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Systems</Trans>
</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/#/logs")} target="_blank">
<LogsIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Logs</Trans>
</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/#/settings/backups")} target="_blank">
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Backups</Trans>
</span>
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
</DropdownMenuGroup>
{isAdmin() && (
<>
{AdminLinks}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onSelect={logOut}>
<LogOutIcon className="me-2.5 h-4 w-4" />
<span>
@@ -149,7 +219,12 @@ export default function Navbar() {
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AddSystemButton className="ms-2" />
{!isReadOnlyUser() && (
<Button variant="outline" className="flex gap-1 ms-2" onClick={() => setAddSystemDialogOpen(true)}>
<PlusIcon className="h-4 w-4 -ms-1" />
<Trans>Add {{ foo: systemTranslation }}</Trans>
</Button>
)}
</div>
</div>
)
@@ -161,28 +236,41 @@ const Kbd = ({ children }: { children: React.ReactNode }) => (
</kbd>
)
function SearchButton() {
const [open, setOpen] = useState(false)
function AdminDropdownGroup() {
return (
<>
<Button
variant="outline"
className="hidden md:block text-sm text-muted-foreground px-4"
onClick={() => setOpen(true)}
>
<span className="flex items-center">
<SearchIcon className="me-1.5 h-4 w-4" />
<Trans>Search</Trans>
<span className="flex items-center ms-3.5">
<Kbd>{isMac ? "⌘" : "Ctrl"}</Kbd>
<Kbd>K</Kbd>
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/")} target="_blank">
<UsersIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Users</Trans>
</span>
</span>
</Button>
<Suspense>
<CommandPalette open={open} setOpen={setOpen} />
</Suspense>
</>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/#/collections?collection=systems")} target="_blank">
<ServerIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Systems</Trans>
</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/#/logs")} target="_blank">
<LogsIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Logs</Trans>
</span>
</a>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={prependBasePath("/_/#/settings/backups")} target="_blank">
<DatabaseBackupIcon className="me-2.5 h-4 w-4" />
<span>
<Trans>Backups</Trans>
</span>
</a>
</DropdownMenuItem>
</DropdownMenuGroup>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,133 @@
import { t } from "@lingui/core/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import { useStore } from "@nanostores/react"
import { XIcon } from "lucide-react"
import React, { type JSX, memo, useCallback, useEffect, useState } from "react"
import { $containerFilter, $maxValues } from "@/lib/stores"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
import Spinner from "../../spinner"
import { Button } from "../../ui/button"
import { Card, CardDescription, CardHeader, CardTitle } from "../../ui/card"
import { ChartAverage, ChartMax } from "../../ui/icons"
import { Input } from "../../ui/input"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"
export function FilterBar({ store = $containerFilter }: { store?: typeof $containerFilter }) {
const storeValue = useStore(store)
const [inputValue, setInputValue] = useState(storeValue)
const { t } = useLingui()
useEffect(() => {
setInputValue(storeValue)
}, [storeValue])
useEffect(() => {
if (inputValue === storeValue) {
return
}
const handle = window.setTimeout(() => store.set(inputValue), 80)
return () => clearTimeout(handle)
}, [inputValue, storeValue, store])
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setInputValue(value)
}, [])
const handleClear = useCallback(() => {
setInputValue("")
store.set("")
}, [store])
return (
<>
<Input
placeholder={t`Filter...`}
className="ps-4 pe-8 w-full sm:w-44"
onChange={handleChange}
value={inputValue}
/>
{inputValue && (
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Clear"
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100"
onClick={handleClear}
>
<XIcon className="h-4 w-4" />
</Button>
)}
</>
)
}
export const SelectAvgMax = memo(({ max }: { max: boolean }) => {
const Icon = max ? ChartMax : ChartAverage
return (
<Select value={max ? "max" : "avg"} onValueChange={(e) => $maxValues.set(e === "max")}>
<SelectTrigger className="relative ps-10 pe-5 w-full sm:w-44">
<Icon className="h-4 w-4 absolute start-4 top-1/2 -translate-y-1/2 opacity-85" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem key="avg" value="avg">
<Trans>Average</Trans>
</SelectItem>
<SelectItem key="max" value="max">
<Trans comment="Chart select field. Please try to keep this short.">Max 1 min</Trans>
</SelectItem>
</SelectContent>
</Select>
)
})
export function ChartCard({
title,
description,
children,
grid,
empty,
cornerEl,
legend,
className,
}: {
title: string
description: string
children: React.ReactNode
grid?: boolean
empty?: boolean
cornerEl?: JSX.Element | null
legend?: boolean
className?: string
}) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<Card
className={cn(
"px-3 py-5 sm:py-6 sm:px-6 odd:last-of-type:col-span-full min-h-full",
{ "col-span-full": !grid },
className
)}
ref={ref}
>
<CardHeader className="gap-1.5 relative p-0 mb-3 sm:mb-4">
<CardTitle>{title}</CardTitle>
<CardDescription>{description}</CardDescription>
{cornerEl && <div className="grid sm:justify-end sm:absolute sm:top-0 sm:end-0 my-1 sm:my-0">{cornerEl}</div>}
</CardHeader>
<div className={cn("ps-0 -me-1 -ms-3.5 relative group", legend ? "h-54 md:h-56" : "h-48 md:h-52")}>
{
<Spinner
msg={empty ? t`Waiting for enough records to display` : undefined}
className="group-has-[.opacity-100]:invisible duration-100"
/>
}
{isIntersecting && children}
</div>
</Card>
)
}

View File

@@ -0,0 +1,116 @@
import { timeTicks } from "d3-time"
import { getPbTimestamp, pb } from "@/lib/api"
import { chartTimeData } from "@/lib/utils"
import type { ChartData, ChartTimes, ContainerStatsRecord, SystemStatsRecord } from "@/types"
type ChartTimeData = {
time: number
data: {
ticks: number[]
domain: number[]
}
chartTime: ChartTimes
}
export const cache = new Map<
string,
ChartTimeData | SystemStatsRecord[] | ContainerStatsRecord[] | ChartData["containerData"]
>()
// create ticks and domain for charts
export function getTimeData(chartTime: ChartTimes, lastCreated: number) {
const cached = cache.get("td") as ChartTimeData | undefined
if (cached && cached.chartTime === chartTime) {
if (!lastCreated || cached.time >= lastCreated) {
return cached.data
}
}
// const buffer = chartTime === "1m" ? 400 : 20_000
const now = new Date(Date.now())
const startTime = chartTimeData[chartTime].getOffset(now)
const ticks = timeTicks(startTime, now, chartTimeData[chartTime].ticks ?? 12).map((date) => date.getTime())
const data = {
ticks,
domain: [chartTimeData[chartTime].getOffset(now).getTime(), now.getTime()],
}
cache.set("td", { time: now.getTime(), data, chartTime })
return data
}
/** Append new records onto prev with gap detection. Converts string `created` values to ms timestamps in place.
* Pass `maxLen` to cap the result length in one copy instead of slicing again after the call. */
export function appendData<T extends { created: string | number | null }>(
prev: T[],
newRecords: T[],
expectedInterval: number,
maxLen?: number
): T[] {
if (!newRecords.length) return prev
// Pre-trim prev so the single slice() below is the only copy we make
const trimmed = maxLen && prev.length >= maxLen ? prev.slice(-(maxLen - newRecords.length)) : prev
const result = trimmed.slice()
let prevTime = (trimmed.at(-1)?.created as number) ?? 0
for (const record of newRecords) {
if (record.created !== null) {
if (typeof record.created === "string") {
record.created = new Date(record.created).getTime()
}
if (prevTime && (record.created as number) - prevTime > expectedInterval * 1.5) {
result.push({ created: null, ...("stats" in record ? { stats: null } : {}) } as T)
}
prevTime = record.created as number
}
result.push(record)
}
return result
}
export async function getStats<T extends SystemStatsRecord | ContainerStatsRecord>(
collection: string,
systemId: string,
chartTime: ChartTimes
): Promise<T[]> {
const cachedStats = cache.get(`${systemId}_${chartTime}_${collection}`) as T[] | undefined
const lastCached = cachedStats?.at(-1)?.created as number
return await pb.collection<T>(collection).getFullList({
filter: pb.filter("system={:id} && created > {:created} && type={:type}", {
id: systemId,
created: getPbTimestamp(chartTime, lastCached ? new Date(lastCached + 1000) : undefined),
type: chartTimeData[chartTime].type,
}),
fields: "created,stats",
sort: "created",
})
}
export function makeContainerData(containers: ContainerStatsRecord[]): ChartData["containerData"] {
const result = [] as ChartData["containerData"]
for (const { created, stats } of containers) {
if (!created) {
result.push({ created: null } as ChartData["containerData"][0])
continue
}
result.push(makeContainerPoint(new Date(created).getTime(), stats))
}
return result
}
/** Transform a single realtime container stats message into a ChartDataContainer point. */
export function makeContainerPoint(
created: number,
stats: ContainerStatsRecord["stats"]
): ChartData["containerData"][0] {
const point: ChartData["containerData"][0] = { created } as ChartData["containerData"][0]
for (const container of stats) {
;(point as Record<string, unknown>)[container.n] = container
}
return point
}
export function dockerOrPodman(str: string, isPodman: boolean): string {
if (isPodman) {
return str.replace("docker", "podman").replace("Docker", "Podman")
}
return str
}

View File

@@ -0,0 +1,99 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { decimalString, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData } from "@/types"
import { pinnedAxisDomain } from "@/components/ui/chart"
import CpuCoresSheet from "../cpu-sheet"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
export function CpuChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`CPU Usage`}
description={t`Average system-wide CPU utilization`}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<CpuCoresSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
dataPoints={[
{
label: t`CPU Usage`,
dataKey: ({ stats }) => (showMax ? stats?.cpum : stats?.cpu),
color: 1,
opacity: 0.4,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
/>
</ChartCard>
)
}
export function ContainerCpuChart({
chartData,
grid,
dataEmpty,
isPodman,
cpuConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
cpuConfig: ChartConfig
}) {
const { filter, dataPoints } = useContainerDataPoints(cpuConfig, (key, data) => data[key]?.c ?? null)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker CPU Usage`, isPodman)}
description={t`Average CPU utilization of containers`}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}

View File

@@ -0,0 +1,283 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { SystemStatsRecord } from "@/types"
import { ChartCard, SelectAvgMax } from "../chart-card"
import { Unit } from "@/lib/enums"
import { pinnedAxisDomain } from "@/components/ui/chart"
import DiskIoSheet from "../disk-io-sheet"
import type { SystemData } from "../use-system-data"
import { useStore } from "@nanostores/react"
import { $userSettings } from "@/lib/stores"
// Helpers for indexed dios/diosm access
const dios =
(i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.dios?.[i] ?? 0
const diosMax =
(i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.diosm?.[i] ?? 0
const extraDios =
(name: string, i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.dios?.[i] ?? 0
const extraDiosMax =
(name: string, i: number) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.diosm?.[i] ?? 0
export const diskDataFns = {
// usage
usage: ({ stats }: SystemStatsRecord) => stats?.du ?? 0,
extraUsage:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.du ?? 0,
// throughput
read: ({ stats }: SystemStatsRecord) => stats?.dio?.[0] ?? (stats?.dr ?? 0) * 1024 * 1024,
readMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[0] ?? (stats?.drm ?? 0) * 1024 * 1024,
write: ({ stats }: SystemStatsRecord) => stats?.dio?.[1] ?? (stats?.dw ?? 0) * 1024 * 1024,
writeMax: ({ stats }: SystemStatsRecord) => stats?.diom?.[1] ?? (stats?.dwm ?? 0) * 1024 * 1024,
// extra fs throughput
extraRead:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.rb ?? (stats?.efs?.[name]?.r ?? 0) * 1024 * 1024,
extraReadMax:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.rbm ?? (stats?.efs?.[name]?.rm ?? 0) * 1024 * 1024,
extraWrite:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.wb ?? (stats?.efs?.[name]?.w ?? 0) * 1024 * 1024,
extraWriteMax:
(name: string) =>
({ stats }: SystemStatsRecord) =>
stats?.efs?.[name]?.wbm ?? (stats?.efs?.[name]?.wm ?? 0) * 1024 * 1024,
// read/write time
readTime: dios(0),
readTimeMax: diosMax(0),
extraReadTime: (name: string) => extraDios(name, 0),
extraReadTimeMax: (name: string) => extraDiosMax(name, 0),
writeTime: dios(1),
writeTimeMax: diosMax(1),
extraWriteTime: (name: string) => extraDios(name, 1),
extraWriteTimeMax: (name: string) => extraDiosMax(name, 1),
// utilization (IoTime-based, 0-100%)
util: dios(2),
utilMax: diosMax(2),
extraUtil: (name: string) => extraDios(name, 2),
extraUtilMax: (name: string) => extraDiosMax(name, 2),
// r_await / w_await: average service time per read/write operation (ms)
rAwait: dios(3),
rAwaitMax: diosMax(3),
extraRAwait: (name: string) => extraDios(name, 3),
extraRAwaitMax: (name: string) => extraDiosMax(name, 3),
wAwait: dios(4),
wAwaitMax: diosMax(4),
extraWAwait: (name: string) => extraDios(name, 4),
extraWAwaitMax: (name: string) => extraDiosMax(name, 4),
// average queue depth: stored as queue_depth * 100 in Go, divided here
weightedIO: ({ stats }: SystemStatsRecord) => (stats?.dios?.[5] ?? 0) / 100,
weightedIOMax: ({ stats }: SystemStatsRecord) => (stats?.diosm?.[5] ?? 0) / 100,
extraWeightedIO:
(name: string) =>
({ stats }: SystemStatsRecord) =>
(stats?.efs?.[name]?.dios?.[5] ?? 0) / 100,
extraWeightedIOMax:
(name: string) =>
({ stats }: SystemStatsRecord) =>
(stats?.efs?.[name]?.diosm?.[5] ?? 0) / 100,
}
export function RootDiskCharts({ systemData }: { systemData: SystemData }) {
return (
<>
<DiskUsageChart systemData={systemData} />
<DiskIOChart systemData={systemData} />
</>
)
}
export function DiskUsageChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
const { chartData, grid, dataEmpty } = systemData
let diskSize = chartData.systemStats?.at(-1)?.stats.d ?? NaN
if (extraFsName) {
diskSize = chartData.systemStats?.at(-1)?.stats.efs?.[extraFsName]?.d ?? NaN
}
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
const title = extraFsName ? `${extraFsName} ${t`Usage`}` : t`Disk Usage`
const description = extraFsName ? t`Disk usage of ${extraFsName}` : t`Usage of root partition`
return (
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description}>
<AreaChartDefault
chartData={chartData}
domain={[0, diskSize]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue)} ${unit}`
}}
dataPoints={[
{
label: t`Disk Usage`,
color: 4,
opacity: 0.4,
dataKey: extraFsName ? diskDataFns.extraUsage(extraFsName) : diskDataFns.usage,
},
]}
></AreaChartDefault>
</ChartCard>
)
}
export function DiskIOChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const userSettings = useStore($userSettings)
if (!chartData.systemStats?.length) {
return null
}
const title = extraFsName ? `${extraFsName} I/O` : t`Disk I/O`
const description = extraFsName ? t`Throughput of ${extraFsName}` : t`Throughput of root filesystem`
const hasMoreIOMetrics = chartData.systemStats?.some((record) => record.stats?.dios?.at(0))
let CornerEl = maxValSelect
if (hasMoreIOMetrics) {
CornerEl = (
<div className="flex gap-2">
{maxValSelect}
<DiskIoSheet systemData={systemData} extraFsName={extraFsName} title={title} description={description} />
</div>
)
}
let readFn = showMax ? diskDataFns.readMax : diskDataFns.read
let writeFn = showMax ? diskDataFns.writeMax : diskDataFns.write
if (extraFsName) {
readFn = showMax ? diskDataFns.extraReadMax(extraFsName) : diskDataFns.extraRead(extraFsName)
writeFn = showMax ? diskDataFns.extraWriteMax(extraFsName) : diskDataFns.extraWrite(extraFsName)
}
return (
<ChartCard empty={dataEmpty} grid={grid} title={title} description={description} cornerEl={CornerEl}>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
// domain={pinnedAxisDomain(true)}
showTotal={true}
dataPoints={[
{
label: t({ message: "Write", comment: "Disk write" }),
dataKey: writeFn,
color: 3,
opacity: 0.3,
},
{
label: t({ message: "Read", comment: "Disk read" }),
dataKey: readFn,
color: 1,
opacity: 0.3,
},
]}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitDisk, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, true, userSettings.unitDisk, false)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
/>
</ChartCard>
)
}
export function DiskUtilizationChart({ systemData, extraFsName }: { systemData: SystemData; extraFsName?: string }) {
const { chartData, grid, dataEmpty, showMax, isLongerChart, maxValues } = systemData
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
if (!chartData.systemStats?.length) {
return null
}
let utilFn = showMax ? diskDataFns.utilMax : diskDataFns.util
if (extraFsName) {
utilFn = showMax ? diskDataFns.extraUtilMax(extraFsName) : diskDataFns.extraUtil(extraFsName)
}
return (
<ChartCard
cornerEl={maxValSelect}
empty={dataEmpty}
grid={grid}
title={t({
message: `I/O Utilization`,
context: "Percent of time the disk is busy with I/O",
})}
description={t`Percent of time the disk is busy with I/O`}
// legend={true}
className="min-h-auto"
>
<AreaChartDefault
chartData={chartData}
domain={pinnedAxisDomain()}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
maxToggled={showMax}
chartProps={{ syncId: "io" }}
dataPoints={[
{
label: t({ message: "Utilization", context: "Disk I/O utilization" }),
dataKey: utilFn,
color: 1,
opacity: 0.4,
},
]}
/>
</ChartCard>
)
}
export function ExtraFsCharts({ systemData }: { systemData: SystemData }) {
const { systemStats } = systemData.chartData
const extraFs = systemStats?.at(-1)?.stats.efs
if (!extraFs || Object.keys(extraFs).length === 0) {
return null
}
return (
<div className="grid xl:grid-cols-2 gap-4">
{Object.keys(extraFs).map((extraFsName) => {
let diskSize = systemStats.at(-1)?.stats.efs?.[extraFsName].d ?? NaN
// round to nearest GB
if (diskSize >= 100) {
diskSize = Math.round(diskSize)
}
return (
<div key={extraFsName} className="contents">
<DiskUsageChart systemData={systemData} extraFsName={extraFsName} />
<DiskIOChart systemData={systemData} extraFsName={extraFsName} />
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,232 @@
import { t } from "@lingui/core/macro"
import { useRef, useMemo } from "react"
import AreaChartDefault, { type DataPoint } from "@/components/charts/area-chart"
import LineChartDefault from "@/components/charts/line-chart"
import { Unit } from "@/lib/enums"
import { cn, decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData, GPUData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../chart-card"
/** GPU power draw chart for the main grid */
export function GpuPowerChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const packageKey = " package"
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Derive GPU power config key (cheap per render)
let gpuPowerKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const gpus = chartData.systemStats[i].stats?.g
if (gpus) {
const parts: string[] = []
for (const id in gpus) {
const gpu = gpus[id] as GPUData
if (gpu.p !== undefined) parts.push(`${id}:${gpu.n}`)
if (gpu.pp !== undefined) parts.push(`${id}:${gpu.n}${packageKey}`)
}
gpuPowerKey = parts.sort().join("\0")
break
}
}
const dataPoints = useMemo((): DataPoint[] => {
if (!gpuPowerKey) return []
const totals = new Map<string, { label: string; gpuId: string; isPackage: boolean; total: number }>()
for (const record of statsRef.current) {
const gpus = record.stats?.g
if (!gpus) continue
for (const id in gpus) {
const gpu = gpus[id] as GPUData
const key = gpu.n
const existing = totals.get(key)
if (existing) {
existing.total += gpu.p ?? 0
} else {
totals.set(key, { label: gpu.n, gpuId: id, isPackage: false, total: gpu.p ?? 0 })
}
if (gpu.pp !== undefined) {
const pkgKey = `${gpu.n}${packageKey}`
const existingPkg = totals.get(pkgKey)
if (existingPkg) {
existingPkg.total += gpu.pp
} else {
totals.set(pkgKey, { label: pkgKey, gpuId: id, isPackage: true, total: gpu.pp })
}
}
}
}
const sorted = Array.from(totals.values()).sort((a, b) => b.total - a.total)
return sorted.map(
(entry, i): DataPoint => ({
label: entry.label,
dataKey: (data: SystemStatsRecord) => {
const gpu = data.stats?.g?.[entry.gpuId]
return entry.isPackage ? (gpu?.pp ?? 0) : (gpu?.p ?? 0)
},
color: `hsl(${226 + (((i * 360) / sorted.length) % 360)}, 65%, 52%)`,
opacity: 1,
})
)
}, [gpuPowerKey])
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`GPU Power Draw`}
description={t`Average power consumption of GPUs`}
>
<LineChartDefault
legend={dataPoints.length > 1}
chartData={chartData}
dataPoints={dataPoints}
itemSorter={(a: { value: number }, b: { value: number }) => b.value - a.value}
tickFormatter={(val) => `${toFixedFloat(val, 2)}W`}
contentFormatter={({ value }) => `${decimalString(value)}W`}
/>
</ChartCard>
)
}
/** GPU detail grid (engines + per-GPU usage/VRAM) — rendered outside the main 2-col grid */
export function GpuDetailCharts({
chartData,
grid,
dataEmpty,
lastGpus,
hasGpuEnginesData,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
lastGpus: Record<string, GPUData>
hasGpuEnginesData: boolean
}) {
return (
<div className="grid xl:grid-cols-2 gap-4">
{hasGpuEnginesData && (
<ChartCard
legend={true}
empty={dataEmpty}
grid={grid}
title={t`GPU Engines`}
description={t`Average utilization of GPU engines`}
>
<GpuEnginesChart chartData={chartData} />
</ChartCard>
)}
{Object.keys(lastGpus).map((id) => {
const gpu = lastGpus[id] as GPUData
return (
<div key={id} className="contents">
<ChartCard
className={cn(grid && "!col-span-1")}
empty={dataEmpty}
grid={grid}
title={`${gpu.n} ${t`Usage`}`}
description={t`Average utilization of ${gpu.n}`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.u ?? 0,
color: 1,
opacity: 0.35,
},
]}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
</ChartCard>
{(gpu.mt ?? 0) > 0 && (
<ChartCard
empty={dataEmpty}
grid={grid}
title={`${gpu.n} VRAM`}
description={t`Precise utilization at the recorded time`}
>
<AreaChartDefault
chartData={chartData}
dataPoints={[
{
label: t`Usage`,
dataKey: ({ stats }) => stats?.g?.[id]?.mu ?? 0,
color: 2,
opacity: 0.25,
},
]}
max={gpu.mt}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value, false, Unit.Bytes, true)
return `${decimalString(convertedValue)} ${unit}`
}}
/>
</ChartCard>
)}
</div>
)
})}
</div>
)
}
function GpuEnginesChart({ chartData }: { chartData: ChartData }) {
// Derive stable engine config key (cheap per render)
let enginesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const gpus = chartData.systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (gpus[id].e) {
enginesKey = id + "\0" + Object.keys(gpus[id].e).sort().join("\0")
break
}
}
if (enginesKey) break
}
const { gpuId, dataPoints } = useMemo((): { gpuId: string | null; dataPoints: DataPoint[] } => {
if (!enginesKey) return { gpuId: null, dataPoints: [] }
const parts = enginesKey.split("\0")
const gId = parts[0]
const engineNames = parts.slice(1)
return {
gpuId: gId,
dataPoints: engineNames.map((engine, i) => ({
label: engine,
dataKey: ({ stats }: SystemStatsRecord) => stats?.g?.[gId]?.e?.[engine] ?? 0,
color: `hsl(${140 + (((i * 360) / engineNames.length) % 360)}, 65%, 52%)`,
opacity: 0.35,
})),
}
}, [enginesKey])
if (!gpuId) {
return null
}
return (
<LineChartDefault
legend={true}
chartData={chartData}
dataPoints={dataPoints}
tickFormatter={(val) => `${toFixedFloat(val, 2)}%`}
contentFormatter={({ value }) => `${decimalString(value)}%`}
/>
)
}

View File

@@ -0,0 +1,55 @@
import { t } from "@lingui/core/macro"
import type { ChartData } from "@/types"
import { ChartCard } from "../chart-card"
import LineChartDefault from "@/components/charts/line-chart"
import { decimalString, toFixedFloat } from "@/lib/utils"
export function LoadAverageChart({
chartData,
grid,
dataEmpty,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
}) {
const { major, minor } = chartData.agentVersion
if (major === 0 && minor <= 12) {
return null
}
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Load Average`}
description={t`System load averages over time`}
legend={true}
>
<LineChartDefault
chartData={chartData}
contentFormatter={(item) => decimalString(item.value)}
tickFormatter={(value) => {
return String(toFixedFloat(value, 2))
}}
legend={true}
dataPoints={[
{
label: t({ message: `1 min`, comment: "Load average" }),
color: "hsl(271, 81%, 60%)", // Purple
dataKey: ({ stats }) => stats?.la?.[0],
},
{
label: t({ message: `5 min`, comment: "Load average" }),
color: "hsl(217, 91%, 60%)", // Blue
dataKey: ({ stats }) => stats?.la?.[1],
},
{
label: t({ message: `15 min`, comment: "Load average" }),
color: "hsl(25, 95%, 53%)", // Orange
dataKey: ({ stats }) => stats?.la?.[2],
},
]}
></LineChartDefault>
</ChartCard>
)
}

View File

@@ -0,0 +1,170 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { Unit } from "@/lib/enums"
import type { ChartConfig } from "@/components/ui/chart"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import { pinnedAxisDomain } from "@/components/ui/chart"
export function MemoryChart({
chartData,
grid,
dataEmpty,
showMax,
isLongerChart,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
showMax: boolean
isLongerChart: boolean
maxValues: boolean
}) {
const maxValSelect = isLongerChart ? <SelectAvgMax max={maxValues} /> : null
const totalMem = toFixedFloat(chartData.systemStats.at(-1)?.stats.m ?? 0, 1)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Memory Usage`}
description={t`Precise utilization at the recorded time`}
cornerEl={maxValSelect}
>
<AreaChartDefault
chartData={chartData}
domain={[0, totalMem]}
itemSorter={(a, b) => a.order - b.order}
maxToggled={showMax}
showTotal={true}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={({ value }) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
dataPoints={[
{
label: t`Used`,
dataKey: ({ stats }) => (showMax ? stats?.mm : stats?.mu),
color: 2,
opacity: 0.4,
stackId: "1",
order: 3,
},
{
label: "ZFS ARC",
dataKey: ({ stats }) => (showMax ? null : stats?.mz),
color: "hsla(175 60% 45% / 0.8)",
opacity: 0.5,
order: 2,
},
{
label: t`Cache / Buffers`,
dataKey: ({ stats }) => (showMax ? null : stats?.mb),
color: "hsla(160 60% 45% / 0.5)",
opacity: 0.4,
stackId: "1",
order: 1,
},
]}
/>
</ChartCard>
)
}
export function ContainerMemoryChart({
chartData,
grid,
dataEmpty,
isPodman,
memoryConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
memoryConfig: ChartConfig
}) {
const { filter, dataPoints } = useContainerDataPoints(memoryConfig, (key, data) => data[key]?.m ?? null)
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Memory Usage`, isPodman)}
description={dockerOrPodman(t`Memory usage of docker containers`, isPodman)}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, false, Unit.Bytes, true)
return `${toFixedFloat(value, val >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(item) => {
const { value, unit } = formatBytes(item.value, false, Unit.Bytes, true)
return `${decimalString(value)} ${unit}`
}}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}
export function SwapChart({
chartData,
grid,
dataEmpty,
systemStats,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
systemStats: SystemStatsRecord[]
}) {
// const userSettings = useStore($userSettings)
const hasSwapData = (systemStats.at(-1)?.stats.su ?? 0) > 0
if (!hasSwapData) {
return null
}
return (
<ChartCard empty={dataEmpty} grid={grid} title={t`Swap Usage`} description={t`Swap space used by the system`}>
<AreaChartDefault
chartData={chartData}
domain={[0, () => toFixedFloat(chartData.systemStats.at(-1)?.stats.s ?? 0.04, 2)]}
contentFormatter={({ value }) => {
// mem values are supplied as GB
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${decimalString(convertedValue, convertedValue >= 100 ? 1 : 2)} ${unit}`
}}
tickFormatter={(value) => {
const { value: convertedValue, unit } = formatBytes(value * 1024, false, Unit.Bytes, true)
return `${toFixedFloat(convertedValue, value >= 10 ? 0 : 1)} ${unit}`
}}
dataPoints={[
{
label: t`Used`,
dataKey: ({ stats }) => stats?.su,
color: 2,
opacity: 0.4,
},
]}
></AreaChartDefault>
</ChartCard>
)
}

View File

@@ -0,0 +1,183 @@
import { useMemo } from "react"
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { useContainerDataPoints } from "@/components/charts/hooks"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartConfig } from "@/components/ui/chart"
import { pinnedAxisDomain } from "@/components/ui/chart"
import type { ChartData, SystemStatsRecord } from "@/types"
import { Separator } from "@/components/ui/separator"
import NetworkSheet from "../network-sheet"
import { ChartCard, FilterBar, SelectAvgMax } from "../chart-card"
import { dockerOrPodman } from "../chart-data"
export function BandwidthChart({
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()
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Bandwidth`}
cornerEl={
<div className="flex gap-2">
{maxValSelect}
<NetworkSheet chartData={chartData} dataEmpty={dataEmpty} grid={grid} maxValues={maxValues} />
</div>
}
description={t`Network traffic of public interfaces`}
>
<AreaChartDefault
chartData={chartData}
maxToggled={showMax}
dataPoints={[
{
label: t`Sent`,
dataKey(data: SystemStatsRecord) {
if (showMax) {
return data?.stats?.bm?.[0] ?? (data?.stats?.nsm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[0] ?? (data?.stats?.ns ?? 0) * 1024 * 1024
},
color: 5,
opacity: 0.2,
},
{
label: t`Received`,
dataKey(data: SystemStatsRecord) {
if (showMax) {
return data?.stats?.bm?.[1] ?? (data?.stats?.nrm ?? 0) * 1024 * 1024
}
return data?.stats?.b?.[1] ?? (data?.stats?.nr ?? 0) * 1024 * 1024
},
color: 2,
opacity: 0.2,
},
]
// try to place the lesser number in front for better visibility
.sort(() => (systemStats.at(-1)?.stats.b?.[1] ?? 0) - (systemStats.at(-1)?.stats.b?.[0] ?? 0))}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={(data) => {
const { value, unit } = formatBytes(data.value, true, userSettings.unitNet, false)
return `${decimalString(value, value >= 100 ? 1 : 2)} ${unit}`
}}
showTotal={true}
/>
</ChartCard>
)
}
export function ContainerNetworkChart({
chartData,
grid,
dataEmpty,
isPodman,
networkConfig,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
isPodman: boolean
networkConfig: ChartConfig
}) {
const userSettings = $userSettings.get()
const { filter, dataPoints, filteredKeys } = useContainerDataPoints(networkConfig, (key, data) => {
const payload = data[key]
if (!payload) return null
const sent = payload?.b?.[0] ?? (payload?.ns ?? 0) * 1024 * 1024
const recv = payload?.b?.[1] ?? (payload?.nr ?? 0) * 1024 * 1024
return sent + recv
})
const contentFormatter = useMemo(() => {
const getRxTxBytes = (record?: { b?: [number, number]; ns?: number; nr?: number }) => {
if (record?.b?.length && record.b.length >= 2) {
return [Number(record.b[0]) || 0, Number(record.b[1]) || 0]
}
return [(record?.ns ?? 0) * 1024 * 1024, (record?.nr ?? 0) * 1024 * 1024]
}
const formatRxTx = (recv: number, sent: number) => {
const { value: receivedValue, unit: receivedUnit } = formatBytes(recv, true, userSettings.unitNet, false)
const { value: sentValue, unit: sentUnit } = formatBytes(sent, true, userSettings.unitNet, false)
return (
<span className="flex">
{decimalString(receivedValue)} {receivedUnit}
<span className="opacity-70 ms-0.5"> rx </span>
<Separator orientation="vertical" className="h-3 mx-1.5 bg-primary/40" />
{decimalString(sentValue)} {sentUnit}
<span className="opacity-70 ms-0.5"> tx</span>
</span>
)
}
// biome-ignore lint/suspicious/noExplicitAny: recharts tooltip item
return (item: any, key: string) => {
try {
if (key === "__total__") {
let totalSent = 0
let totalRecv = 0
const payloadData = item?.payload && typeof item.payload === "object" ? item.payload : {}
for (const [containerKey, value] of Object.entries(payloadData)) {
if (!value || typeof value !== "object") continue
if (filteredKeys.has(containerKey)) continue
const [sent, recv] = getRxTxBytes(value as { b?: [number, number]; ns?: number; nr?: number })
totalSent += sent
totalRecv += recv
}
return formatRxTx(totalRecv, totalSent)
}
const [sent, recv] = getRxTxBytes(item?.payload?.[key])
return formatRxTx(recv, sent)
} catch {
return null
}
}
}, [filteredKeys, userSettings.unitNet])
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={dockerOrPodman(t`Docker Network I/O`, isPodman)}
description={dockerOrPodman(t`Network traffic of docker containers`, isPodman)}
cornerEl={<FilterBar />}
>
<AreaChartDefault
chartData={chartData}
customData={chartData.containerData}
dataPoints={dataPoints}
tickFormatter={(val) => {
const { value, unit } = formatBytes(val, true, userSettings.unitNet, false)
return `${toFixedFloat(value, value >= 10 ? 0 : 1)} ${unit}`
}}
contentFormatter={contentFormatter}
domain={pinnedAxisDomain()}
showTotal={true}
reverseStackOrder={true}
filter={filter}
truncate={true}
itemSorter={(a, b) => b.value - a.value}
/>
</ChartCard>
)
}

View File

@@ -0,0 +1,209 @@
import { t } from "@lingui/core/macro"
import AreaChartDefault from "@/components/charts/area-chart"
import { batteryStateTranslations } from "@/lib/i18n"
import { $temperatureFilter, $userSettings } from "@/lib/stores"
import { cn, decimalString, formatTemperature, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard, FilterBar } from "../chart-card"
import LineChartDefault from "@/components/charts/line-chart"
import { useStore } from "@nanostores/react"
import { useRef, useMemo, useState, useEffect } from "react"
export function BatteryChart({
chartData,
grid,
dataEmpty,
maxValues,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
maxValues: boolean
}) {
const showBatteryChart = chartData.systemStats.at(-1)?.stats.bat
if (!showBatteryChart) {
return null
}
return (
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Battery`}
description={`${t({
message: "Current state",
comment: "Context: Battery state",
})}: ${batteryStateTranslations[chartData.systemStats.at(-1)?.stats.bat?.[1] ?? 0]()}`}
>
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
dataPoints={[
{
label: t`Charge`,
dataKey: ({ stats }) => stats?.bat?.[0],
color: 1,
opacity: 0.35,
},
]}
domain={[0, 100]}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
/>
</ChartCard>
)
}
export function TemperatureChart({
chartData,
grid,
dataEmpty,
setPageBottomExtraMargin,
}: {
chartData: ChartData
grid: boolean
dataEmpty: boolean
setPageBottomExtraMargin?: (margin: number) => void
}) {
const showTempChart = chartData.systemStats.at(-1)?.stats.t
const filter = useStore($temperatureFilter)
const userSettings = useStore($userSettings)
const statsRef = useRef(chartData.systemStats)
statsRef.current = chartData.systemStats
// Derive sensor names key from latest data point
let sensorNamesKey = ""
for (let i = chartData.systemStats.length - 1; i >= 0; i--) {
const t = chartData.systemStats[i].stats?.t
if (t) {
sensorNamesKey = Object.keys(t).sort().join("\0")
break
}
}
// Only recompute colors and dataKey functions when sensor names change
const { colorMap, dataKeys, sortedKeys } = useMemo(() => {
const stats = statsRef.current
const tempSums = {} as Record<string, number>
for (const data of stats) {
const t = data.stats?.t
if (!t) continue
for (const key of Object.keys(t)) {
tempSums[key] = (tempSums[key] ?? 0) + t[key]
}
}
const sorted = Object.keys(tempSums).sort((a, b) => tempSums[b] - tempSums[a])
const colorMap = {} as Record<string, string>
const dataKeys = {} as Record<string, (d: SystemStatsRecord) => number | undefined>
for (let i = 0; i < sorted.length; i++) {
const key = sorted[i]
colorMap[key] = `hsl(${((i * 360) / sorted.length) % 360}, 60%, 55%)`
dataKeys[key] = (d: SystemStatsRecord) => d.stats?.t?.[key]
}
return { colorMap, dataKeys, sortedKeys: sorted }
}, [sensorNamesKey])
const dataPoints = useMemo(() => {
return sortedKeys.map((key) => {
const filterTerms = filter
? filter
.toLowerCase()
.split(" ")
.filter((term) => term.length > 0)
: []
const filtered = filterTerms.length > 0 && !filterTerms.some((term) => key.toLowerCase().includes(term))
const strokeOpacity = filtered ? 0.1 : 1
return {
label: key,
dataKey: dataKeys[key],
color: colorMap[key],
opacity: strokeOpacity,
}
})
}, [sortedKeys, filter, dataKeys, colorMap])
// test with lots of data points
// const totalPoints = 50
// if (dataPoints.length > 0 && dataPoints.length < totalPoints) {
// let i = 0
// while (dataPoints.length < totalPoints) {
// dataPoints.push({
// label: `Test ${++i}`,
// dataKey: () => 0,
// color: "red",
// opacity: 1,
// })
// }
// }
const chartRef = useRef<HTMLDivElement>(null)
const [addMargin, setAddMargin] = useState(false)
const marginPx = (dataPoints.length - 13) * 18
useEffect(() => {
if (setPageBottomExtraMargin && dataPoints.length > 13 && chartRef.current) {
const checkPosition = () => {
if (!chartRef.current) return
const rect = chartRef.current.getBoundingClientRect()
const actualScrollHeight = addMargin
? document.documentElement.scrollHeight - marginPx
: document.documentElement.scrollHeight
const distanceToBottom = actualScrollHeight - (rect.bottom + window.scrollY)
if (distanceToBottom < 250) {
setAddMargin(true)
setPageBottomExtraMargin(marginPx)
} else {
setAddMargin(false)
setPageBottomExtraMargin(0)
}
}
checkPosition()
const timer = setTimeout(checkPosition, 500)
return () => {
clearTimeout(timer)
}
} else if (addMargin) {
setAddMargin(false)
if (setPageBottomExtraMargin) setPageBottomExtraMargin(0)
}
}, [dataPoints.length, addMargin, marginPx, setPageBottomExtraMargin])
if (!showTempChart) {
return null
}
const legend = dataPoints.length < 12
return (
<div ref={chartRef} className={cn("odd:last-of-type:col-span-full", { "col-span-full": !grid })}>
<ChartCard
empty={dataEmpty}
grid={grid}
title={t`Temperature`}
description={t`Temperatures of system sensors`}
cornerEl={<FilterBar store={$temperatureFilter} />}
legend={legend}
>
<LineChartDefault
chartData={chartData}
itemSorter={(a, b) => b.value - a.value}
domain={["auto", "auto"]}
legend={legend}
tickFormatter={(val) => {
const { value, unit } = formatTemperature(val, userSettings.unitTemp)
return `${toFixedFloat(value, 2)} ${unit}`
}}
contentFormatter={(item) => {
const { value, unit } = formatTemperature(item.value, userSettings.unitTemp)
return `${decimalString(value)} ${unit}`
}}
dataPoints={dataPoints}
></LineChartDefault>
</ChartCard>
</div>
)
}

View File

@@ -1,14 +1,14 @@
import { t } from "@lingui/core/macro"
import { MoreHorizontalIcon } from "lucide-react"
import { memo, useRef, useState } from "react"
import AreaChartDefault, { DataPoint } from "@/components/charts/area-chart"
import AreaChartDefault, { type DataPoint } 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 { compareSemVer, decimalString, parseSemVer, toFixedFloat } from "@/lib/utils"
import type { ChartData, SystemStatsRecord } from "@/types"
import { ChartCard } from "../system"
import { ChartCard } from "./chart-card"
const minAgentVersion = parseSemVer("0.15.3")
@@ -42,41 +42,54 @@ export default memo(function CpuCoresSheet({
const numCores = cpus.length
const hasBreakdown = (latest?.cpub?.length ?? 0) > 0
// make sure all individual core data points have the same y axis domain to make relative comparison easier
let highestCpuCorePct = 1
if (hasOpened.current) {
for (let i = 0; i < numCores; i++) {
for (let j = 0; j < chartData.systemStats.length; j++) {
const pct = chartData.systemStats[j].stats?.cpus?.[i] ?? 0
if (pct > highestCpuCorePct) {
highestCpuCorePct = pct
}
}
}
}
const breakdownDataPoints = [
{
label: "System",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[1],
color: 3,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "User",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[0],
color: 1,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "IOWait",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[2],
color: 4,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "Steal",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[3],
color: 5,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: "Idle",
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpub?.[4],
color: 2,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
{
label: t`Other`,
@@ -86,11 +99,10 @@ export default memo(function CpuCoresSheet({
},
color: `hsl(80, 65%, 52%)`,
opacity: 0.35,
stackId: "a"
stackId: "a",
},
] as DataPoint[]
return (
<Sheet open={cpuCoresOpen} onOpenChange={setCpuCoresOpen}>
<DialogTitle className="sr-only">{t`CPU Usage`}</DialogTitle>
@@ -99,7 +111,7 @@ export default memo(function CpuCoresSheet({
title={t`View more`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
>
<MoreHorizontalIcon />
</Button>
@@ -151,7 +163,7 @@ export default memo(function CpuCoresSheet({
dataKey: ({ stats }: SystemStatsRecord) => stats?.cpus?.[i] ?? 1 / (stats?.cpus?.length ?? 1),
color: `hsl(${226 + (((i * 360) / Math.max(1, numCores)) % 360)}, var(--chart-saturation), var(--chart-lightness))`,
opacity: 0.35,
stackId: "a"
stackId: "a",
}))}
tickFormatter={(val) => `${val}%`}
contentFormatter={({ value }) => `${value}%`}
@@ -174,7 +186,7 @@ export default memo(function CpuCoresSheet({
<AreaChartDefault
chartData={chartData}
maxToggled={maxValues}
legend={false}
domain={[0, highestCpuCorePct]}
dataPoints={[
{
label: t`Usage`,

View 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>
)
})

View File

@@ -1,20 +1,28 @@
import { plural } from "@lingui/core/macro"
import { useLingui } from "@lingui/react/macro"
import { Trans, useLingui } from "@lingui/react/macro"
import {
AppleIcon,
ChevronRightSquareIcon,
ClockArrowUp,
CpuIcon,
GlobeIcon,
LayoutGridIcon,
MemoryStickIcon,
MonitorIcon,
Rows,
Settings2Icon,
} from "lucide-react"
import { useMemo } from "react"
import ChartTimeSelect from "@/components/charts/chart-time-select"
import { Button } from "@/components/ui/button"
import { Card } from "@/components/ui/card"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { FreeBsdIcon, TuxIcon, WebSocketIcon, WindowsIcon } from "@/components/ui/icons"
import { Separator } from "@/components/ui/separator"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
@@ -27,12 +35,16 @@ export default function InfoBar({
chartData,
grid,
setGrid,
displayMode,
setDisplayMode,
details,
}: {
system: SystemRecord
chartData: ChartData
grid: boolean
setGrid: (grid: boolean) => void
displayMode: "default" | "tabs"
setDisplayMode: (mode: "default" | "tabs") => void
details: SystemDetailsRecord | null
}) {
const { t } = useLingui()
@@ -123,10 +135,10 @@ export default function InfoBar({
return (
<Card>
<div className="grid xl:flex gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div>
<h1 className="text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex flex-wrap items-center gap-3 gap-y-2 text-sm opacity-90">
<div className="grid xl:flex xl:gap-4 px-4 sm:px-6 pt-3 sm:pt-4 pb-5">
<div className="min-w-0">
<h1 className="text-2xl sm:text-[1.6rem] font-semibold mb-1.5">{system.name}</h1>
<div className="flex xl:flex-wrap items-center py-4 xl:p-0 -mt-3 xl:mt-1 gap-3 text-sm text-nowrap opacity-90 overflow-x-auto scrollbar-hide -mx-4 px-4 xl:mx-0">
<Tooltip>
<TooltipTrigger asChild>
<div className="capitalize flex gap-2 items-center">
@@ -190,24 +202,53 @@ export default function InfoBar({
</div>
<div className="xl:ms-auto flex items-center gap-2 max-sm:-mb-1">
<ChartTimeSelect className="w-full xl:w-40" agentVersion={chartData.agentVersion} />
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={t`Toggle grid`}
aria-label={t`Settings`}
variant="outline"
size="icon"
className="hidden xl:flex p-0 text-primary"
onClick={() => setGrid(!grid)}
>
{grid ? (
<LayoutGridIcon className="h-[1.2rem] w-[1.2rem] opacity-75" />
) : (
<Rows className="h-[1.3rem] w-[1.3rem] opacity-75" />
)}
<Settings2Icon className="size-4 opacity-90" />
</Button>
</TooltipTrigger>
<TooltipContent>{t`Toggle grid`}</TooltipContent>
</Tooltip>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
<DropdownMenuLabel className="px-3.5">
<Trans context="Layout display options">Display</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={displayMode}
onValueChange={(v) => setDisplayMode(v as "default" | "tabs")}
>
<DropdownMenuRadioItem value="default" onSelect={(e) => e.preventDefault()}>
<Trans context="Default system layout option">Default</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="tabs" onSelect={(e) => e.preventDefault()}>
<Trans context="Tabs system layout option">Tabs</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-3.5">
<Trans>Chart width</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
className="px-1 pb-1"
value={grid ? "grid" : "full"}
onValueChange={(v) => setGrid(v === "grid")}
>
<DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()}>
<Trans>Grid</Trans>
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="full" onSelect={(e) => e.preventDefault()}>
<Trans>Full</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>

View File

@@ -0,0 +1,36 @@
import { lazy } from "react"
import { useIntersectionObserver } from "@/lib/use-intersection-observer"
import { cn } from "@/lib/utils"
const ContainersTable = lazy(() => import("../../containers-table/containers-table"))
export function LazyContainersTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <ContainersTable systemId={systemId} />}
</div>
)
}
const SmartTable = lazy(() => import("./smart-table"))
export function LazySmartTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver({ rootMargin: "90px" })
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SmartTable systemId={systemId} />}
</div>
)
}
const SystemdTable = lazy(() => import("../../systemd-table/systemd-table"))
export function LazySystemdTable({ systemId }: { systemId: string }) {
const { isIntersecting, ref } = useIntersectionObserver()
return (
<div ref={ref} className={cn(isIntersecting && "contents")}>
{isIntersecting && <SystemdTable systemId={systemId} />}
</div>
)
}

View File

@@ -11,7 +11,7 @@ import { DialogTitle } from "@/components/ui/dialog"
import { $userSettings } from "@/lib/stores"
import { decimalString, formatBytes, toFixedFloat } from "@/lib/utils"
import type { ChartData } from "@/types"
import { ChartCard } from "../system"
import { ChartCard } from "./chart-card"
export default memo(function NetworkSheet({
chartData,
@@ -46,7 +46,7 @@ export default memo(function NetworkSheet({
title={t`View more`}
variant="outline"
size="icon"
className="shrink-0 max-sm:absolute max-sm:top-3 max-sm:end-3"
className="shrink-0 max-sm:absolute max-sm:top-0 max-sm:end-0"
>
<MoreHorizontalIcon />
</Button>

View File

@@ -36,7 +36,7 @@ import { Input } from "@/components/ui/input"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { pb } from "@/lib/api"
import { isReadOnlyUser, pb } from "@/lib/api"
import type { SmartDeviceRecord, SmartAttribute } from "@/types"
import {
formatBytes,
@@ -146,7 +146,9 @@ export const createColumns = (
{
accessorKey: "model",
sortingFn: (a, b) => a.original.model.localeCompare(b.original.model),
header: ({ column }) => <HeaderButton column={column} name={t`Model`} Icon={Box} />,
header: ({ column }) => (
<HeaderButton column={column} name={t({ message: "Model", comment: "Device model" })} Icon={Box} />
),
cell: ({ getValue }) => (
<div
className="max-w-48 truncate ms-1"
@@ -490,7 +492,7 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
const tableColumns = useMemo(() => {
const columns = createColumns(longestName, longestModel, longestDevice)
const baseColumns = systemId ? columns.filter((col) => col.id !== "system") : columns
return [...baseColumns, actionColumn]
return isReadOnlyUser() ? baseColumns : [...baseColumns, actionColumn]
}, [systemId, actionColumn, longestName, longestModel, longestDevice])
const table = useReactTable({
@@ -532,9 +534,9 @@ export default function DisksTable({ systemId }: { systemId?: string }) {
return (
<div>
<Card className="p-6 @container w-full">
<CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end">
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">S.M.A.R.T.</CardTitle>
<CardDescription className="flex">
@@ -620,11 +622,13 @@ const SmartDevicesTable = memo(function SmartDevicesTable({
return <SmartDeviceTableRow key={row.id} row={row} virtualRow={virtualRow} openSheet={openSheet} />
})
) : (
<TableRow>
<TableCell colSpan={colLength} className="h-24 text-center pointer-events-none">
{data ? t`No results.` : <LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />}
</TableCell>
</TableRow>
<TableCell colSpan={colLength} className="h-37 text-center pointer-events-none">
{data ? (
<Trans>No results.</Trans>
) : (
<LoaderCircleIcon className="animate-spin size-10 opacity-60 mx-auto" />
)}
</TableCell>
)}
</TableBody>
</table>
@@ -636,7 +640,6 @@ const SmartDevicesTable = memo(function SmartDevicesTable({
function SmartTableHead({ table }: { table: TableType<SmartDeviceRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (

View File

@@ -0,0 +1,346 @@
import { useStore } from "@nanostores/react"
import { getPagePath } from "@nanostores/router"
import { subscribeKeys } from "nanostores"
import { useEffect, useMemo, useRef, useState } from "react"
import { useContainerChartConfigs } from "@/components/charts/hooks"
import { pb } from "@/lib/api"
import { SystemStatus } from "@/lib/enums"
import {
$allSystemsById,
$allSystemsByName,
$chartTime,
$containerFilter,
$direction,
$maxValues,
$systems,
$userSettings,
} from "@/lib/stores"
import { chartTimeData, listen, parseSemVer, useBrowserStorage } from "@/lib/utils"
import type {
ChartData,
ContainerStatsRecord,
SystemDetailsRecord,
SystemInfo,
SystemRecord,
SystemStats,
SystemStatsRecord,
} from "@/types"
import { $router, navigate } from "../../router"
import { appendData, cache, getStats, getTimeData, makeContainerData, makeContainerPoint } from "./chart-data"
export type SystemData = ReturnType<typeof useSystemData>
export function useSystemData(id: string) {
const direction = useStore($direction)
const systems = useStore($systems)
const chartTime = useStore($chartTime)
const maxValues = useStore($maxValues)
const [grid, setGrid] = useBrowserStorage("grid", true)
const [displayMode, setDisplayMode] = useBrowserStorage<"default" | "tabs">("displayMode", "default")
const [activeTab, setActiveTabRaw] = useState("core")
const [mountedTabs, setMountedTabs] = useState(() => new Set<string>(["core"]))
const tabsRef = useRef<string[]>(["core", "disk"])
function setActiveTab(tab: string) {
setActiveTabRaw(tab)
setMountedTabs((prev) => (prev.has(tab) ? prev : new Set([...prev, tab])))
}
const [system, setSystem] = useState({} as SystemRecord)
const [systemStats, setSystemStats] = useState([] as SystemStatsRecord[])
const [containerData, setContainerData] = useState([] as ChartData["containerData"])
const persistChartTime = useRef(false)
const statsRequestId = useRef(0)
const [chartLoading, setChartLoading] = useState(true)
const [details, setDetails] = useState<SystemDetailsRecord>({} as SystemDetailsRecord)
useEffect(() => {
return () => {
if (!persistChartTime.current) {
$chartTime.set($userSettings.get().chartTime)
}
persistChartTime.current = false
setSystemStats([])
setContainerData([])
setDetails({} as SystemDetailsRecord)
$containerFilter.set("")
}
}, [id])
// find matching system and update when it changes
useEffect(() => {
if (!systems.length) {
return
}
// allow old system-name slug to work
const store = $allSystemsById.get()[id] ? $allSystemsById : $allSystemsByName
return subscribeKeys(store, [id], (newSystems) => {
const sys = newSystems[id]
if (sys) {
setSystem(sys)
document.title = `${sys?.name} / Beszel`
}
})
}, [id, systems.length])
// hide 1m chart time if system agent version is less than 0.13.0
useEffect(() => {
if (parseSemVer(system?.info?.v) < parseSemVer("0.13.0")) {
$chartTime.set("1h")
}
}, [system?.info?.v])
// fetch system details
useEffect(() => {
// if system.info.m exists, agent is old version without system details
if (!system.id || system.info?.m) {
return
}
pb.collection<SystemDetailsRecord>("system_details")
.getOne(system.id, {
fields: "hostname,kernel,cores,threads,cpu,os,os_name,arch,memory,podman",
headers: {
"Cache-Control": "public, max-age=60",
},
})
.then(setDetails)
}, [system.id])
// subscribe to realtime metrics if chart time is 1m
useEffect(() => {
let unsub = () => {}
if (!system.id || chartTime !== "1m") {
return
}
if (system.status !== SystemStatus.Up || parseSemVer(system?.info?.v).minor < 13) {
$chartTime.set("1h")
return
}
let isFirst = true
pb.realtime
.subscribe(
`rt_metrics`,
(data: { container: ContainerStatsRecord[]; info: SystemInfo; stats: SystemStats }) => {
const now = Date.now()
const statsPoint = { created: now, stats: data.stats } as SystemStatsRecord
const containerPoint =
data.container?.length > 0
? makeContainerPoint(now, data.container as unknown as ContainerStatsRecord["stats"])
: null
// on first message, make sure we clear out data from other time periods
if (isFirst) {
isFirst = false
setSystemStats([statsPoint])
setContainerData(containerPoint ? [containerPoint] : [])
return
}
setSystemStats((prev) => appendData(prev, [statsPoint], 1000, 60))
if (containerPoint) {
setContainerData((prev) => appendData(prev, [containerPoint], 1000, 60))
}
},
{ query: { system: system.id } }
)
.then((us) => {
unsub = us
})
return () => {
unsub?.()
}
}, [chartTime, system.id])
const agentVersion = useMemo(() => parseSemVer(system?.info?.v), [system?.info?.v])
const chartData: ChartData = useMemo(() => {
const lastCreated = Math.max(
(systemStats.at(-1)?.created as number) ?? 0,
(containerData.at(-1)?.created as number) ?? 0
)
return {
systemStats,
containerData,
chartTime,
orientation: direction === "rtl" ? "right" : "left",
...getTimeData(chartTime, lastCreated),
agentVersion,
}
}, [systemStats, containerData, direction])
// Share chart config computation for all container charts
const containerChartConfigs = useContainerChartConfigs(containerData)
// get stats when system "changes." (Not just system to system,
// also when new info comes in via systemManager realtime connection, indicating an update)
useEffect(() => {
if (!system.id || !chartTime || chartTime === "1m") {
return
}
const systemId = system.id
const { expectedInterval } = chartTimeData[chartTime]
const ss_cache_key = `${systemId}_${chartTime}_system_stats`
const cs_cache_key = `${systemId}_${chartTime}_container_stats`
const requestId = ++statsRequestId.current
const cachedSystemStats = cache.get(ss_cache_key) as SystemStatsRecord[] | undefined
const cachedContainerData = cache.get(cs_cache_key) as ChartData["containerData"] | undefined
// Render from cache immediately if available
if (cachedSystemStats?.length) {
setSystemStats(cachedSystemStats)
setContainerData(cachedContainerData || [])
setChartLoading(false)
// Skip the fetch if the latest cached point is recent enough that no new point is expected yet
const lastCreated = cachedSystemStats.at(-1)?.created as number | undefined
if (lastCreated && Date.now() - lastCreated < expectedInterval * 0.9) {
return
}
} else {
setChartLoading(true)
}
Promise.allSettled([
getStats<SystemStatsRecord>("system_stats", systemId, chartTime),
getStats<ContainerStatsRecord>("container_stats", systemId, chartTime),
]).then(([systemStats, containerStats]) => {
// If another request has been made since this one, ignore the results
if (requestId !== statsRequestId.current) {
return
}
setChartLoading(false)
// make new system stats
let systemData = (cache.get(ss_cache_key) || []) as SystemStatsRecord[]
if (systemStats.status === "fulfilled" && systemStats.value.length) {
systemData = appendData(systemData, systemStats.value, expectedInterval, 100)
cache.set(ss_cache_key, systemData)
}
setSystemStats(systemData)
// make new container stats
let containerData = (cache.get(cs_cache_key) || []) as ChartData["containerData"]
if (containerStats.status === "fulfilled" && containerStats.value.length) {
containerData = appendData(containerData, makeContainerData(containerStats.value), expectedInterval, 100)
cache.set(cs_cache_key, containerData)
}
setContainerData(containerData)
})
}, [system, chartTime])
// keyboard navigation between systems
// in tabs mode: arrow keys switch tabs, shift+arrow switches systems
// in default mode: arrow keys switch systems
useEffect(() => {
if (!systems.length) {
return
}
const handleKeyUp = (e: KeyboardEvent) => {
if (
e.target instanceof HTMLInputElement ||
e.target instanceof HTMLTextAreaElement ||
e.ctrlKey ||
e.metaKey ||
e.altKey
) {
return
}
const isLeft = e.key === "ArrowLeft" || e.key === "h"
const isRight = e.key === "ArrowRight" || e.key === "l"
if (!isLeft && !isRight) {
return
}
// in tabs mode, plain arrows switch tabs, shift+arrows switch systems
if (displayMode === "tabs") {
if (!e.shiftKey) {
// skip if focused in tablist (Radix handles it natively)
if (e.target instanceof HTMLElement && e.target.closest('[role="tablist"]')) {
return
}
const tabs = tabsRef.current
const currentIdx = tabs.indexOf(activeTab)
const nextIdx = isLeft ? (currentIdx - 1 + tabs.length) % tabs.length : (currentIdx + 1) % tabs.length
setActiveTab(tabs[nextIdx])
return
}
} else if (e.shiftKey) {
return
}
const currentIndex = systems.findIndex((s) => s.id === id)
if (currentIndex === -1 || systems.length <= 1) {
return
}
if (isLeft) {
const prevIndex = (currentIndex - 1 + systems.length) % systems.length
persistChartTime.current = true
setActiveTabRaw("core")
setMountedTabs(new Set(["core"]))
return navigate(getPagePath($router, "system", { id: systems[prevIndex].id }))
}
if (isRight) {
const nextIndex = (currentIndex + 1) % systems.length
persistChartTime.current = true
setActiveTabRaw("core")
setMountedTabs(new Set(["core"]))
return navigate(getPagePath($router, "system", { id: systems[nextIndex].id }))
}
}
return listen(document, "keyup", handleKeyUp)
}, [id, systems, displayMode, activeTab])
// derived values
const isLongerChart = !["1m", "1h"].includes(chartTime)
const showMax = maxValues && isLongerChart
const dataEmpty = !chartLoading && chartData.systemStats.length === 0
const lastGpus = systemStats.at(-1)?.stats?.g
const isPodman = details?.podman ?? system.info?.p ?? false
let hasGpuData = false
let hasGpuEnginesData = false
let hasGpuPowerData = false
if (lastGpus) {
hasGpuData = Object.keys(lastGpus).length > 0
for (let i = 0; i < systemStats.length && (!hasGpuEnginesData || !hasGpuPowerData); i++) {
const gpus = systemStats[i].stats?.g
if (!gpus) continue
for (const id in gpus) {
if (!hasGpuEnginesData && gpus[id].e !== undefined) {
hasGpuEnginesData = true
}
if (!hasGpuPowerData && (gpus[id].p !== undefined || gpus[id].pp !== undefined)) {
hasGpuPowerData = true
}
if (hasGpuEnginesData && hasGpuPowerData) break
}
}
}
return {
system,
systemStats,
containerData,
chartData,
containerChartConfigs,
details,
grid,
setGrid,
displayMode,
setDisplayMode,
activeTab,
setActiveTab,
mountedTabs,
tabsRef,
maxValues,
isLongerChart,
showMax,
dataEmpty,
isPodman,
lastGpus,
hasGpuData,
hasGpuEnginesData,
hasGpuPowerData,
}
}

View File

@@ -18,7 +18,7 @@ import { listenKeys } from "nanostores"
import { memo, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { getStatusColor, systemdTableCols } from "@/components/systemd-table/systemd-table-columns"
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 { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet"
import { TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
@@ -154,20 +154,20 @@ export default function SystemdTable({ systemId }: { systemId?: string }) {
}
return (
<Card className="p-6 @container w-full">
<CardHeader className="p-0 mb-4">
<div className="grid md:flex gap-5 w-full items-end">
<Card className="@container w-full px-3 py-5 sm:py-6 sm:px-6">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>Systemd Services</Trans>
</CardTitle>
<CardDescription className="flex items-center">
<div className="text-sm text-muted-foreground flex items-center flex-wrap">
<Trans>Total: {data.length}</Trans>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Failed: {statusTotals[ServiceStatus.Failed]}</Trans>
<Separator orientation="vertical" className="h-4 mx-2 bg-primary/40" />
<Trans>Updated every 10 minutes.</Trans>
</CardDescription>
</div>
</div>
<Input
placeholder={t`Filter...`}
@@ -614,7 +614,6 @@ function SystemdSheet({
function SystemdTableHead({ table }: { table: TableType<SystemdRecord> }) {
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@@ -134,8 +134,8 @@ export default function SystemsTable() {
const CardHead = useMemo(() => {
return (
<CardHeader className="pb-4.5 px-2 sm:px-6 max-sm:pt-5 max-sm:pb-1">
<div className="grid md:flex gap-5 w-full items-end">
<CardHeader className="p-0 mb-3 sm:mb-4">
<div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
<div className="px-2 sm:px-1">
<CardTitle className="mb-2">
<Trans>All Systems</Trans>
@@ -302,29 +302,27 @@ export default function SystemsTable() {
])
return (
<Card>
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
{CardHead}
<div className="p-6 pt-0 max-sm:py-3 max-sm:px-2">
{viewMode === "table" ? (
// table layout
<div className="rounded-md">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
) : (
// grid layout
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{rows?.length ? (
rows.map((row) => {
return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.length} />
})
) : (
<div className="col-span-full text-center py-8">
<Trans>No systems found.</Trans>
</div>
)}
</div>
)}
</div>
{viewMode === "table" ? (
// table layout
<div className="rounded-md">
<AllSystemsTable table={table} rows={rows} colLength={visibleColumns.length} />
</div>
) : (
// grid layout
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{rows?.length ? (
rows.map((row) => {
return <SystemCard key={row.original.id} row={row} table={table} colLength={visibleColumns.length} />
})
) : (
<div className="col-span-full text-center py-8">
<Trans>No systems found.</Trans>
</div>
)}
</div>
)}
</Card>
)
}
@@ -391,7 +389,6 @@ function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
const { t } = useLingui()
return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2">
<div className="absolute -top-2 left-0 w-full h-4 bg-table-header z-50"></div>
{table.getHeaderGroups().map((headerGroup) => (
<tr key={headerGroup.id}>
{headerGroup.headers.map((header) => {
@@ -463,14 +460,14 @@ const SystemCard = memo(
}
)}
>
<CardHeader className="py-1 ps-5 pe-3 bg-muted/30 border-b border-border/60">
<div className="flex items-center gap-2 w-full overflow-hidden">
<CardTitle className="text-base tracking-normal text-primary/90 flex items-center min-w-0 flex-1 gap-2.5">
<CardHeader className="py-1 ps-4 pe-2 bg-muted/30 border-b border-border/60">
<div className="flex items-center gap-1 w-full overflow-hidden">
<h3 className="text-primary/90 min-w-0 flex-1 gap-2.5 font-semibold">
<div className="flex items-center gap-2.5 min-w-0 flex-1">
<IndicatorDot system={system} />
<span className="text-[.95em]/normal tracking-normal text-primary/90 truncate">{system.name}</span>
</div>
</CardTitle>
</h3>
{table.getColumn("actions")?.getIsVisible() && (
<div className="flex gap-1 shrink-0 relative z-10">
<AlertButton system={system} />

View File

@@ -43,7 +43,7 @@ const AlertDialogContent = React.forwardRef<
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-2 text-center sm:text-start", className)} {...props} />
<div className={cn("grid gap-2 text-start", className)} {...props} />
)
AlertDialogHeader.displayName = "AlertDialogHeader"

View File

@@ -18,7 +18,7 @@ CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3 ref={ref} className={cn("text-2xl font-semibold leading-none tracking-tight", className)} {...props} />
<h3 ref={ref} className={cn("text-card-title font-semibold leading-none tracking-tight", className)} {...props} />
)
)
CardTitle.displayName = "CardTitle"

View File

@@ -52,7 +52,7 @@ const DialogContent = React.forwardRef<
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("grid gap-1.5 text-center sm:text-start", className)} {...props} />
<div className={cn("grid gap-1.5 text-start", className)} {...props} />
)
DialogHeader.displayName = "DialogHeader"

View File

@@ -25,7 +25,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-hidden focus:bg-accent/70 data-[state=open]:bg-accent/70",
"flex select-none items-center rounded-sm px-2.5 py-1.5 text-[.95em] outline-hidden focus:bg-accent/70 data-[state=open]:bg-accent/70",
inset && "ps-8",
className
)}
@@ -79,7 +79,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"cursor-pointer relative flex select-none items-center rounded-sm px-2.5 py-1.5 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"cursor-pointer relative flex select-none items-center rounded-sm px-2.5 py-1.5 text-[.95em] outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
inset && "ps-8",
className
)}
@@ -95,7 +95,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-[.95em] outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
checked={checked}
@@ -118,7 +118,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-sm outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
"relative flex cursor-pointer select-none items-center rounded-sm py-1.5 ps-8 pe-2.5 text-[.95em] outline-hidden focus:bg-accent/70 focus:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50",
className
)}
{...props}
@@ -141,7 +141,7 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn("px-2.5 py-1.5 text-sm font-semibold", inset && "ps-8", className)}
className={cn("px-2.5 py-1.5 text-[.95em] font-semibold", inset && "ps-8", className)}
{...props}
/>
))

View File

@@ -27,7 +27,7 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs cursor-pointer",
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-xs cursor-pointer hover:text-foreground",
className
)}
{...props}

View File

@@ -147,6 +147,12 @@
button {
cursor: pointer;
}
/* cosmetic patch for half pixel gap in table headers when scrolling content shows at top */
thead.sticky:before {
content: "";
@apply absolute -top-2 left-0 w-full h-4 bg-table-header z-50
}
}
@utility container {
@@ -163,6 +169,18 @@
min-width: 30.3rem;
}
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
@utility text-card-title {
@apply text-[1.4rem] sm:text-2xl;
}
.recharts-tooltip-wrapper {
z-index: 51;
@apply tabular-nums;

View File

@@ -1,5 +1,5 @@
import { atom, computed, listenKeys, map, type ReadableAtom } from "nanostores"
import type { AlertMap, ChartTimes, SystemRecord, UserSettings } from "@/types"
import type { AlertMap, ChartTimes, SystemRecord, UpdateInfo, UserSettings } from "@/types"
import { pb } from "./api"
import { Unit } from "./enums"
@@ -28,6 +28,9 @@ export const $alerts = map<AlertMap>({})
/** SSH public key */
export const $publicKey = atom("")
/** New version info if an update is available, otherwise undefined */
export const $newVersion = atom<UpdateInfo | undefined>()
/** Chart time period */
export const $chartTime = atom<ChartTimes>("1h")

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: ar\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-04-05 18:27\n"
"Last-Translator: \n"
"Language-Team: Arabic\n"
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
@@ -18,6 +18,12 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "{0} متاح"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -49,7 +55,7 @@ msgid "1 hour"
msgstr "1 ساعة"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "دقيقة واحدة"
@@ -66,7 +72,7 @@ msgid "12 hours"
msgstr "12 ساعة"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 دقيقة"
@@ -79,7 +85,7 @@ msgid "30 days"
msgstr "30 يومًا"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 دقائق"
@@ -107,19 +113,14 @@ msgid "Active state"
msgstr "الحالة النشطة"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "إضافة {foo}"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "إضافة <0>نظام</0>"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "إضافة نظام"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "إضافة رابط"
@@ -134,6 +135,7 @@ msgstr "تعديل عرض التخطيط الرئيسي"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "مسؤول"
@@ -163,6 +165,7 @@ msgstr "التنبيهات"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "جميع الحاويات"
@@ -188,11 +191,11 @@ msgstr "هل أنت متأكد؟"
msgid "Automatic copy requires a secure context."
msgstr "النسخ التلقائي يتطلب سياقًا آمنًا."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "متوسط"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "متوسط استخدام وحدة المعالجة المركزية للحاويات"
@@ -206,20 +209,29 @@ msgstr "المتوسط ينخفض أقل من <0>{value}{0}</0>"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "المتوسط يتجاوز <0>{value}{0}</0>"
#: src/components/routes/system.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Average number of I/O operations waiting to be serviced"
msgstr "متوسط عدد عمليات الإدخال والإخراج التي تنتظر خدمتها"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average power consumption of GPUs"
msgstr "متوسط ​​استهلاك طاقة وحدة معالجة الرسوميات"
#: src/components/routes/system.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average operation time (iostat await)"
msgid "Average queue to completion time per operation"
msgstr "متوسط الوقت من الانتظار في الدور حتى الإتمام لكل عملية"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average system-wide CPU utilization"
msgstr "متوسط استخدام وحدة المعالجة المركزية على مستوى النظام"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "متوسط ​​استخدام {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "متوسط استغلال محركات GPU"
@@ -228,7 +240,7 @@ msgstr "متوسط استغلال محركات GPU"
msgid "Backups"
msgstr "النسخ الاحتياطية"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "عرض النطاق الترددي"
@@ -238,7 +250,7 @@ msgstr "عرض النطاق الترددي"
msgid "Bat"
msgstr "بطارية"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "البطارية"
@@ -288,7 +300,7 @@ msgstr "حالة التمهيد"
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "بايت (كيلوبايت/ثانية، ميجابايت/ثانية، جيجابايت/ثانية)"
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "ذاكرة التخزين المؤقت / المخازن المؤقتة"
@@ -334,7 +346,7 @@ msgstr "تغيير وحدات عرض المقاييس."
msgid "Change general application options."
msgstr "تغيير خيارات التطبيق العامة."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "الشحن"
@@ -347,6 +359,10 @@ msgstr "قيد الشحن"
msgid "Chart options"
msgstr "خيارات الرسم البياني"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "عرض الرسم البياني"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "تحقق من {email} للحصول على رابط إعادة التعيين."
@@ -407,6 +423,10 @@ msgstr "التعارضات"
msgid "Connection is down"
msgstr "الاتصال مقطوع"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "الحاويات"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -433,6 +453,11 @@ msgctxt "Environment variables"
msgid "Copy env"
msgstr "نسخ متغيرات البيئة"
#: src/components/alerts/alerts-sheet.tsx
msgctxt "Copy alerts from another system"
msgid "Copy from"
msgstr "نسخ من"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Copy host"
msgstr "نسخ المضيف"
@@ -462,6 +487,11 @@ msgstr "انسخ محتوى <0>docker-compose.yml</0> للوكيل أدناه،
msgid "Copy YAML"
msgstr "نسخ YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "النواة"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -484,8 +514,8 @@ msgstr "وقت المعالج"
msgid "CPU Time Breakdown"
msgstr "تفصيل وقت المعالج"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -517,7 +547,7 @@ msgid "Cumulative Upload"
msgstr "الرفع التراكمي"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "الحالة الحالية"
@@ -531,6 +561,11 @@ msgstr "الدورات"
msgid "Daily"
msgstr "يوميًا"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "افتراضي"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "الفترة الزمنية الافتراضية"
@@ -563,11 +598,12 @@ msgstr "الجهاز"
msgid "Discharging"
msgstr "قيد التفريغ"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "القرص"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "إدخال/إخراج القرص"
@@ -575,25 +611,30 @@ msgstr "إدخال/إخراج القرص"
msgid "Disk unit"
msgstr "وحدة القرص"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "استخدام القرص"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "استخدام القرص لـ {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "عرض"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "استخدام المعالج للدوكر"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "استخدام الذاكرة للدوكر"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "إدخال/إخراج الشبكة للدوكر"
@@ -770,7 +811,7 @@ msgstr "فشل: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
@@ -800,6 +841,7 @@ msgid "FreeBSD command"
msgstr "أمر FreeBSD"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "ممتلئة"
@@ -815,10 +857,14 @@ msgid "Global"
msgstr "عالمي"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr "معالج الرسوميات"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "محركات GPU"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "استهلاك طاقة وحدة معالجة الرسوميات"
@@ -826,6 +872,7 @@ msgstr "استهلاك طاقة وحدة معالجة الرسوميات"
msgid "GPU Usage"
msgstr "استخدام وحدة معالجة الرسوميات"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "شبكة"
@@ -864,6 +911,21 @@ msgstr "طريقة HTTP"
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "طريقة HTTP: POST، GET، أو HEAD (الافتراضي: POST)"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average operation time (iostat await)"
msgid "I/O Await"
msgstr "انتظار الإدخال والإخراج"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O total time spent on read/write"
msgid "I/O Time"
msgstr "وقت الإدخال والإخراج"
#: src/components/routes/system/charts/disk-charts.tsx
msgctxt "Percent of time the disk is busy with I/O"
msgid "I/O Utilization"
msgstr "استخدام الإدخال والإخراج"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -912,7 +974,7 @@ msgstr "دورة الحياة"
msgid "limit"
msgstr "الحد"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "متوسط التحميل"
@@ -941,6 +1003,7 @@ msgstr "حالة التحميل"
msgid "Loading..."
msgstr "جاري التحميل..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "تسجيل الخروج"
@@ -978,7 +1041,7 @@ msgid "Manual setup instructions"
msgstr "تعليمات الإعداد اليدوي"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "الحد الأقصى دقيقة"
@@ -999,15 +1062,16 @@ msgstr "حد الذاكرة"
msgid "Memory Peak"
msgstr "ذروة الذاكرة"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "استخدام الذاكرة"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "استخدام الذاكرة لحاويات دوكر"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "الموديل"
@@ -1025,11 +1089,11 @@ msgstr "الاسم"
msgid "Net"
msgstr "الشبكة"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "حركة مرور الشبكة لحاويات الدوكر"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1171,6 +1235,10 @@ msgstr "تنسيق الحمولة"
msgid "Per-core average utilization"
msgstr "متوسط الاستخدام لكل نواة"
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Percent of time the disk is busy with I/O"
msgstr "النسبة المئوية للوقت الذي يكون فيه القرص مشغولاً بالإدخال والإخراج"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "النسبة المئوية للوقت المقضي في كل حالة"
@@ -1220,13 +1288,18 @@ msgstr "يرجى تسجيل الدخول إلى حسابك"
msgid "Port"
msgstr "المنفذ"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr "المنافذ"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "تشغيل الطاقة"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "الاستخدام الدقيق في الوقت المسجل"
@@ -1243,17 +1316,24 @@ msgstr "تم بدء العملية"
msgid "Public Key"
msgstr "المفتاح العام"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average queue depth"
msgid "Queue Depth"
msgstr "عمق الدور"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Quiet Hours"
msgstr "ساعات الهدوء"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "قراءة"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "تم الاستلام"
@@ -1326,6 +1406,10 @@ msgstr "تفاصيل S.M.A.R.T."
msgid "S.M.A.R.T. Self-Test"
msgstr "اختبار S.M.A.R.T. الذاتي"
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "حفظ {foo}"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو الفاصلة. اتركه فارغًا لتعطيل إشعارات البريد الإشباكي."
@@ -1335,10 +1419,6 @@ msgstr "احفظ العنوان باستخدام مفتاح الإدخال أو
msgid "Save Settings"
msgstr "حفظ الإعدادات"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "احفظ النظام"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "محفوظ في قاعدة البيانات ولا ينتهي حتى تقوم بتعطيله."
@@ -1387,7 +1467,7 @@ msgstr "أرسل pings صادرة دورية إلى خدمة مراقبة خار
msgid "Send test heartbeat"
msgstr "إرسال نبضة قلب اختبارية"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "تم الإرسال"
@@ -1399,6 +1479,7 @@ msgstr "الرقم التسلسلي"
msgid "Service Details"
msgstr "تفاصيل الخدمة"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "الخدمات"
@@ -1414,8 +1495,10 @@ msgstr "قم بتعيين متغيرات البيئة التالية على مر
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "الإعدادات"
@@ -1459,17 +1542,18 @@ msgstr "الحالة"
msgid "Sub State"
msgstr "الحالة الفرعية"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "مساحة التبديل المستخدمة من قبل النظام"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "استخدام التبديل"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1481,7 +1565,7 @@ msgstr "استخدام التبديل"
msgid "System"
msgstr "النظام"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "متوسط تحميل النظام مع مرور الوقت"
@@ -1501,6 +1585,11 @@ msgstr "يمكن إدارة الأنظمة في ملف <0>config.yml</0> داخ
msgid "Table"
msgstr "جدول"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "تبويبات"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "المهام"
@@ -1511,7 +1600,7 @@ msgstr "المهام"
msgid "Temp"
msgstr "درجة الحرارة"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "درجة الحرارة"
@@ -1520,7 +1609,7 @@ msgstr "درجة الحرارة"
msgid "Temperature unit"
msgstr "وحدة درجة الحرارة"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "درجات حرارة مستشعرات النظام"
@@ -1552,11 +1641,11 @@ msgstr "لا يمكن التراجع عن هذا الإجراء. سيؤدي ذل
msgid "This will permanently delete all selected records from the database."
msgstr "سيؤدي هذا إلى حذف جميع السجلات المحددة من قاعدة البيانات بشكل دائم."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "معدل نقل {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "معدل نقل نظام الملفات الجذر"
@@ -1568,11 +1657,6 @@ msgstr "تنسيق الوقت"
msgid "To email(s)"
msgstr "إلى البريد الإشباكي"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "تبديل الشبكة"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1610,6 +1694,11 @@ msgstr "إجمالي البيانات المستلمة لكل واجهة"
msgid "Total data sent for each interface"
msgstr "إجمالي البيانات المرسلة لكل واجهة"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)"
msgstr ""
#. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
@@ -1730,20 +1819,20 @@ msgstr "رفع"
msgid "Uptime"
msgstr "مدة التشغيل"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "الاستخدام"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "استخدام القسم الجذر"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "مستخدم"
@@ -1752,6 +1841,11 @@ msgstr "مستخدم"
msgid "Users"
msgstr "المستخدمون"
#: src/components/routes/system/charts/disk-charts.tsx
msgctxt "Disk I/O utilization"
msgid "Utilization"
msgstr "الاستخدام"
#: src/components/alerts-history-columns.tsx
msgid "Value"
msgstr "القيمة"
@@ -1761,6 +1855,7 @@ msgid "View"
msgstr "عرض"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "عرض المزيد"
@@ -1773,7 +1868,7 @@ msgstr "عرض أحدث 200 تنبيه."
msgid "Visible Fields"
msgstr "الأعمدة الظاهرة"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "في انتظار وجود سجلات كافية للعرض"
@@ -1812,8 +1907,10 @@ msgid "Windows command"
msgstr "أمر ويندوز"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "كتابة"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: bg\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-03-28 09:32\n"
"Last-Translator: \n"
"Language-Team: Bulgarian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -18,6 +18,12 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "Версия {0} е налична"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -49,7 +55,7 @@ msgid "1 hour"
msgstr "1 час"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 минута"
@@ -66,7 +72,7 @@ msgid "12 hours"
msgstr "12 часа"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 минути"
@@ -79,7 +85,7 @@ msgid "30 days"
msgstr "30 дни"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 минути"
@@ -107,19 +113,14 @@ msgid "Active state"
msgstr "Активно състояние"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "Добави {foo}"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "Добави <0>Система</0>"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "Добави система"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Добави URL"
@@ -134,6 +135,7 @@ msgstr "Настройка ширината на основния макет"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "Администратор"
@@ -163,6 +165,7 @@ msgstr "Тревоги"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Всички контейнери"
@@ -188,11 +191,11 @@ msgstr "Сигурни ли сте?"
msgid "Automatic copy requires a secure context."
msgstr "Автоматичното копиране изисква защитен контескт."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "Средно"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "Средно използване на процесора на контейнерите"
@@ -206,20 +209,29 @@ msgstr "Средната стойност пада под <0>{value}{0}</0>"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Средната стойност надхвърля <0>{value}{0}</0>"
#: src/components/routes/system.tsx
#: 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
msgid "Average power consumption of GPUs"
msgstr "Средна консумация на ток от графични карти"
#: src/components/routes/system.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average operation time (iostat await)"
msgid "Average queue to completion time per operation"
msgstr "Средно време в опашката до приключване на операция"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average system-wide CPU utilization"
msgstr "Средно използване на процесора на цялата система"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "Средно използване на {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "Средно използване на GPU двигатели"
@@ -228,7 +240,7 @@ msgstr "Средно използване на GPU двигатели"
msgid "Backups"
msgstr "Архиви"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "Bandwidth на мрежата"
@@ -238,7 +250,7 @@ msgstr "Bandwidth на мрежата"
msgid "Bat"
msgstr "Бат"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "Батерия"
@@ -288,7 +300,7 @@ msgstr "Състояние при зареждане"
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "Байта (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "Кеш / Буфери"
@@ -334,7 +346,7 @@ msgstr "Промяна на единиците за показване на ме
msgid "Change general application options."
msgstr "Смени общите опции на приложението."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "Заряд"
@@ -347,6 +359,10 @@ msgstr "Зареждане"
msgid "Chart options"
msgstr "Опции на диаграмата"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "Ширина на графиката"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "Провери {email} за линк за нулиране."
@@ -357,7 +373,7 @@ msgstr "Провери log-овете за повече информация."
#: src/components/routes/settings/heartbeat.tsx
msgid "Check your monitoring service"
msgstr "Проверете вашата услуга за мониторинг"
msgstr "Проверете мониторинг услугата си"
#: src/components/routes/settings/notifications.tsx
msgid "Check your notification service"
@@ -407,6 +423,10 @@ msgstr "Конфликти"
msgid "Connection is down"
msgstr "Връзката е прекъсната"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "Контейнери"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -433,6 +453,11 @@ msgctxt "Environment variables"
msgid "Copy env"
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
msgid "Copy host"
msgstr "Копирай хоста"
@@ -462,6 +487,11 @@ msgstr "Копирайте съдържанието на<0>docker-compose.yml</0
msgid "Copy YAML"
msgstr "Копирай YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "Основни"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -484,8 +514,8 @@ msgstr "Време на CPU"
msgid "CPU Time Breakdown"
msgstr "Разбивка на времето на CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -517,7 +547,7 @@ msgid "Cumulative Upload"
msgstr "Кумулативно качване"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "Текущо състояние"
@@ -531,6 +561,11 @@ msgstr "Цикли"
msgid "Daily"
msgstr "Дневно"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "Подредба"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "Времеви диапазон по подразбиране"
@@ -563,11 +598,12 @@ msgstr "Устройство"
msgid "Discharging"
msgstr "Разреждане"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Диск"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "Диск I/O"
@@ -575,25 +611,30 @@ msgstr "Диск I/O"
msgid "Disk unit"
msgstr "Единица за диск"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "Използване на диск"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "Изполване на диск от {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "Показване"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "Използване на процесор от docker"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "Изполване на памет от docker"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "Мрежов I/O използван от docker"
@@ -770,7 +811,7 @@ msgstr "Неуспешни: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
@@ -800,6 +841,7 @@ msgid "FreeBSD command"
msgstr "FreeBSD команда"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "Пълна"
@@ -815,10 +857,14 @@ msgid "Global"
msgstr "Глобален"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "GPU двигатели"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "Консумация на ток от графична карта"
@@ -826,6 +872,7 @@ msgstr "Консумация на ток от графична карта"
msgid "GPU Usage"
msgstr "Употреба на GPU"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "Мрежово"
@@ -836,7 +883,7 @@ msgstr "Здраве"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -864,6 +911,21 @@ msgstr "HTTP метод"
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "HTTP метод: POST, GET или HEAD (по подразбиране: POST)"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average operation time (iostat await)"
msgid "I/O Await"
msgstr "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
#: src/lib/i18n.ts
msgid "Idle"
@@ -912,7 +974,7 @@ msgstr "Жизнен цикъл"
msgid "limit"
msgstr "лимит"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "Средно натоварване"
@@ -941,6 +1003,7 @@ msgstr "Състояние на зареждане"
msgid "Loading..."
msgstr "Зареждане..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Изход"
@@ -978,7 +1041,7 @@ msgid "Manual setup instructions"
msgstr "Инструкции за ръчна настройка"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "Максимум 1 минута"
@@ -999,15 +1062,16 @@ msgstr "Лимит на памет"
msgid "Memory Peak"
msgstr "Пик на памет"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "Употреба на паметта"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "Използването на памет от docker контейнерите"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Модел"
@@ -1025,11 +1089,11 @@ msgstr "Име"
msgid "Net"
msgstr "Мрежа"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "Мрежов трафик на docker контейнери"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1171,6 +1235,10 @@ msgstr "Формат на полезния товар"
msgid "Per-core average utilization"
msgstr "Средно използване на ядро"
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Percent of time the disk is busy with I/O"
msgstr "Процент от времето, в което дискът е зает с I/O операции"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "Процент време, прекарано във всяко състояние"
@@ -1220,13 +1288,18 @@ msgstr "Моля влез в акаунта ти"
msgid "Port"
msgstr "Порт"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr "Портове"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "Включване"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "Точно използване в записаното време"
@@ -1243,17 +1316,24 @@ msgstr "Процесът стартира"
msgid "Public Key"
msgstr "Публичен ключ"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average queue depth"
msgid "Queue Depth"
msgstr "Дълбочина на опашката"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Quiet Hours"
msgstr "Тихи часове"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "Прочети"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "Получени"
@@ -1326,6 +1406,10 @@ msgstr "S.M.A.R.T. Детайли"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Самотест"
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "Запази {foo}"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Запази адреса с enter или запетая. Остави празно за да изключиш нотификациите чрез имейл."
@@ -1335,10 +1419,6 @@ msgstr "Запази адреса с enter или запетая. Остави
msgid "Save Settings"
msgstr "Запази настройките"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "Запази система"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Запазен е в базата данни и не изтича, докато не го деактивирате."
@@ -1377,7 +1457,7 @@ msgstr "Избери {foo}"
#: src/components/routes/settings/heartbeat.tsx
msgid "Send a single heartbeat ping to verify your endpoint is working."
msgstr "Изпратете единичен heartbeat пинг, за да проверите дали вашата крайна точка работи."
msgstr "Изпратете единичен heartbeat пинг, за да се уверите, че крайната Ви точка работи."
#: src/components/routes/settings/heartbeat.tsx
msgid "Send periodic outbound pings to an external monitoring service so you can monitor Beszel without exposing it to the internet."
@@ -1387,7 +1467,7 @@ msgstr "Изпращайте периодични изходящи пингов
msgid "Send test heartbeat"
msgstr "Изпращане на тестов heartbeat"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "Изпратени"
@@ -1399,6 +1479,7 @@ msgstr "Сериен номер"
msgid "Service Details"
msgstr "Детайли на услугата"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "Услуги"
@@ -1409,13 +1490,15 @@ msgstr "Задайте процентни прагове за цветовете
#: src/components/routes/settings/heartbeat.tsx
msgid "Set the following environment variables on your Beszel hub to enable heartbeat monitoring:"
msgstr "Задайте следните променливи на средата на вашия Beszel hub, за да активирате мониторинга на heartbeat:"
msgstr "Задайте следните променливи на средата на вашия Beszel hub, за да активирате heartbeat мониторинг:"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "Настройки"
@@ -1459,17 +1542,18 @@ msgstr "Статус"
msgid "Sub State"
msgstr "Подсъстояние"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "Изполван swap от системата"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "Използване на swap"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1481,7 +1565,7 @@ msgstr "Използване на swap"
msgid "System"
msgstr "Система"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "Средно натоварване на системата във времето"
@@ -1501,6 +1585,11 @@ msgstr "Системите могат да бъдат управлявани в
msgid "Table"
msgstr "Таблица"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Табове"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Задачи"
@@ -1511,7 +1600,7 @@ msgstr "Задачи"
msgid "Temp"
msgstr "Температура"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "Температура"
@@ -1520,7 +1609,7 @@ msgstr "Температура"
msgid "Temperature unit"
msgstr "Единица за температура"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "Температири на системни сензори"
@@ -1552,11 +1641,11 @@ msgstr "Това действие не може да бъде отменено.
msgid "This will permanently delete all selected records from the database."
msgstr "Това ще доведе до трайно изтриване на всички избрани записи от базата данни."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "Пропускателна способност на {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "Пропускателна способност на root файловата система"
@@ -1568,11 +1657,6 @@ msgstr "Формат на времето"
msgid "To email(s)"
msgstr "До имейл(ите)"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "Превключване на мрежа"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1610,6 +1694,11 @@ msgstr "Общо получени данни за всеки интерфейс"
msgid "Total data sent for each interface"
msgstr "Общо изпратени данни за всеки интерфейс"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)"
msgstr ""
#. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
@@ -1728,22 +1817,22 @@ msgstr "Качване"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
msgstr "Време на работа"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "Употреба"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "Употреба на root partition-а"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "Използвани"
@@ -1752,6 +1841,11 @@ msgstr "Използвани"
msgid "Users"
msgstr "Потребители"
#: src/components/routes/system/charts/disk-charts.tsx
msgctxt "Disk I/O utilization"
msgid "Utilization"
msgstr "Натоварване"
#: src/components/alerts-history-columns.tsx
msgid "Value"
msgstr "Стойност"
@@ -1761,6 +1855,7 @@ msgid "View"
msgstr "Изглед"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Виж повече"
@@ -1773,7 +1868,7 @@ msgstr "Прегледайте последните си 200 сигнала."
msgid "Visible Fields"
msgstr "Видими полета"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "Изчаква се за достатъчно записи за показване"
@@ -1799,11 +1894,11 @@ msgstr "Webhook / Пуш нотификации"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "When enabled, this token allows agents to self-register without prior system creation."
msgstr "Когато е активиран, този символ позволява на агентите да се регистрират сами без предварително създаване на система."
msgstr "Когато е активиран, този токен позволява на агентите да се регистрират сами без предварително създаване на система."
#: src/components/routes/settings/heartbeat.tsx
msgid "When using POST, each heartbeat includes a JSON payload with system status summary, list of down systems, and triggered alerts."
msgstr "При използване на POST всеки heartbeat включва JSON полезен товар с резюме на състоянието на системата, списък на спрените системи и задействаните предупреждения."
msgstr "При използване на POST, всеки heartbeat включва JSON полезен товар с резюме на състоянието на системата, списък на спрените системи и задействаните предупреждения."
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
@@ -1812,8 +1907,10 @@ msgid "Windows command"
msgstr "Команда Windows"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "Запиши"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: cs\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-04-05 18:27\n"
"Last-Translator: \n"
"Language-Team: Czech\n"
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n>=2 && n<=4) ? 1 : 3;\n"
@@ -18,6 +18,12 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "{0} k dispozici"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -49,9 +55,9 @@ msgid "1 hour"
msgstr "1 hodina"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
@@ -66,9 +72,9 @@ msgid "12 hours"
msgstr "12 hodin"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 min"
msgstr ""
#: src/lib/utils.ts
msgid "24 hours"
@@ -79,9 +85,9 @@ msgid "30 days"
msgstr "30 dní"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 min"
msgstr ""
#. Table column
#: src/components/routes/settings/quiet-hours.tsx
@@ -107,19 +113,14 @@ msgid "Active state"
msgstr "Aktivní stav"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "Přidat {foo}"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "Přidat <0>Systém</0>"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "Přidat systém"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Přidat URL"
@@ -134,6 +135,7 @@ msgstr "Upravit šířku hlavního rozvržení"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "Administrátor"
@@ -147,7 +149,7 @@ msgstr "Po nastavení proměnných prostředí restartujte hub Beszel, aby se zm
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -163,6 +165,7 @@ msgstr "Výstrahy"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Všechny kontejnery"
@@ -188,11 +191,11 @@ msgstr "Jste si jistý?"
msgid "Automatic copy requires a secure context."
msgstr "Automatická kopie vyžaduje zabezpečený kontext."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "Průměr"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "Průměrné využití CPU kontejnerů"
@@ -206,20 +209,29 @@ msgstr "Průměr klesne pod <0>{value}{0}</0>"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Průměr je vyšší než <0>{value}{0}</0>"
#: src/components/routes/system.tsx
#: 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
msgid "Average power consumption of GPUs"
msgstr "Průměrná spotřeba energie GPU"
#: src/components/routes/system.tsx
#: 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
msgid "Average system-wide CPU utilization"
msgstr "Průměrné využití CPU v celém systému"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "Průměrné využití {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "Průměrné využití GPU engine"
@@ -228,7 +240,7 @@ msgstr "Průměrné využití GPU engine"
msgid "Backups"
msgstr "Zálohy"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "Přenos"
@@ -236,9 +248,9 @@ msgstr "Přenos"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr "Bat"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "Baterie"
@@ -288,7 +300,7 @@ msgstr "Stav zavádění"
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "Byty (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "Cache / vyrovnávací paměť"
@@ -334,7 +346,7 @@ msgstr "Změnit jednotky zobrazení metrik."
msgid "Change general application options."
msgstr "Změnit obecné nastavení aplikace."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "Nabíjení"
@@ -347,6 +359,10 @@ msgstr "Nabíjení"
msgid "Chart options"
msgstr "Možnosti grafu"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "Šířka grafu"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "Zkontrolujte {email} pro odkaz na obnovení."
@@ -407,6 +423,10 @@ msgstr "Konflikty"
msgid "Connection is down"
msgstr "Připojení je nedostupné"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "Kontejnery"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -433,6 +453,11 @@ msgctxt "Environment variables"
msgid "Copy 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
msgid "Copy host"
msgstr "Kopírovat hostitele"
@@ -462,6 +487,11 @@ msgstr "Zkopírujte obsah <0>docker-compose.yml</0> pro agenta níže nebo autom
msgid "Copy YAML"
msgstr "Kopírovat YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "Jádro"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -484,8 +514,8 @@ msgstr "Čas CPU"
msgid "CPU Time Breakdown"
msgstr "Rozdělení času CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -517,7 +547,7 @@ msgid "Cumulative Upload"
msgstr "Kumulativní odeslání"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "Aktuální stav"
@@ -531,6 +561,11 @@ msgstr "Cykly"
msgid "Daily"
msgstr "Denně"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "Výchozí"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "Výchozí doba"
@@ -552,7 +587,7 @@ msgstr "Popis"
#: src/components/containers-table/containers-table.tsx
msgid "Detail"
msgstr "Detail"
msgstr ""
#: src/components/routes/system/smart-table.tsx
msgid "Device"
@@ -563,37 +598,43 @@ msgstr "Zařízení"
msgid "Discharging"
msgstr "Vybíjení"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disk"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "Disk I/O"
msgstr "I/O disku"
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Disková jednotka"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "Využití disku"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "Využití disku {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "Zobrazení"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "Využití CPU Dockeru"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "Využití paměti Dockeru"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "Síťové I/O Dockeru"
@@ -770,7 +811,7 @@ msgstr "Neúspěšné: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
@@ -783,7 +824,7 @@ msgstr "Otisk"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -800,6 +841,7 @@ msgid "FreeBSD command"
msgstr "FreeBSD příkaz"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "Plná"
@@ -815,10 +857,14 @@ msgid "Global"
msgstr "Globální"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "GPU enginy"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "Spotřeba energie GPU"
@@ -826,6 +872,7 @@ msgstr "Spotřeba energie GPU"
msgid "GPU Usage"
msgstr "Využití GPU"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "Mřížka"
@@ -836,7 +883,7 @@ msgstr "Zdraví"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -864,6 +911,21 @@ msgstr "HTTP metoda"
msgid "HTTP method: POST, GET, or HEAD (default: 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
#: src/lib/i18n.ts
msgid "Idle"
@@ -884,7 +946,7 @@ msgstr "Neaktivní"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
msgstr ""
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
@@ -910,9 +972,9 @@ msgstr "Životní cyklus"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "limit"
msgstr "limit"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "Průměrné vytížení"
@@ -941,6 +1003,7 @@ msgstr "Stav načtení"
msgid "Loading..."
msgstr "Načítání..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Odhlásit"
@@ -978,7 +1041,7 @@ msgid "Manual setup instructions"
msgstr "Pokyny k manuálnímu nastavení"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "Max. 1 min"
@@ -999,18 +1062,19 @@ msgstr "Limit paměti"
msgid "Memory Peak"
msgstr "Špička paměti"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "Využití paměti"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "Využití paměti docker kontejnerů"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -1025,11 +1089,11 @@ msgstr "Název"
msgid "Net"
msgstr "Síť"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "Síťový provoz kontejnerů docker"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1171,6 +1235,10 @@ msgstr "Formát payloadu"
msgid "Per-core average utilization"
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
msgid "Percentage of time spent in each state"
msgstr "Procento času strávěného v každém stavu"
@@ -1218,15 +1286,20 @@ msgstr "Přihlaste se prosím k vašemu účtu"
#: src/components/add-system.tsx
msgid "Port"
msgstr "Port"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr "Porty"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "Zapnutí"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "Přesné využití v zaznamenaném čase"
@@ -1243,17 +1316,24 @@ msgstr "Proces spuštěn"
msgid "Public Key"
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
msgid "Quiet Hours"
msgstr "Tiché hodiny"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "Číst"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "Přijato"
@@ -1326,6 +1406,10 @@ msgstr "S.M.A.R.T. Detaily"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Vlastní test"
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "Uložit {foo}"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mailových oznámení ponechte prázdné pole."
@@ -1335,10 +1419,6 @@ msgstr "Adresu uložte pomocí klávesy enter nebo čárky. Pro deaktivaci e-mai
msgid "Save Settings"
msgstr "Uložit nastavení"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "Uložit systém"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Uložen v databázi a nevyprší, dokud jej nezablokujete."
@@ -1387,7 +1467,7 @@ msgstr "Odesílejte periodické odchozí pingy na externí monitorovací službu
msgid "Send test heartbeat"
msgstr "Odeslat testovací heartbeat"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "Odeslat"
@@ -1399,6 +1479,7 @@ msgstr "Sériové číslo"
msgid "Service Details"
msgstr "Detaily služby"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "Služby"
@@ -1414,8 +1495,10 @@ msgstr "Pro povolení monitorování heartbeat nastavte na hubu Beszel následuj
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "Nastavení"
@@ -1459,17 +1542,18 @@ msgstr "Stav"
msgid "Sub State"
msgstr "Podstav"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "Swap prostor využívaný systémem"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "Swap využití"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1481,7 +1565,7 @@ msgstr "Swap využití"
msgid "System"
msgstr "Systém"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "Průměry zatížení systému v průběhu času"
@@ -1501,6 +1585,11 @@ msgstr "Systémy lze spravovat v souboru <0>config.yml</0> uvnitř datového adr
msgid "Table"
msgstr "Tabulka"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Karty"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Úlohy"
@@ -1511,7 +1600,7 @@ msgstr "Úlohy"
msgid "Temp"
msgstr "Teplota"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "Teplota"
@@ -1520,7 +1609,7 @@ msgstr "Teplota"
msgid "Temperature unit"
msgstr "Jednotky teploty"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "Teploty systémových senzorů"
@@ -1552,11 +1641,11 @@ 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."
msgstr "Tímto trvale odstraníte všechny vybrané záznamy z databáze."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "Propustnost {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "Propustnost kořenového souborového systému"
@@ -1568,11 +1657,6 @@ msgstr "Formát času"
msgid "To email(s)"
msgstr "Na email(y)"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "Přepnout mřížku"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1581,7 +1665,7 @@ msgstr "Přepnout motiv"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1610,6 +1694,11 @@ msgstr "Celkový přijatý objem dat pro každé rozhraní"
msgid "Total data sent for each interface"
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
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
@@ -1728,22 +1817,22 @@ msgstr "Odeslání"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
msgstr "Doba provozu"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "Využití"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "Využití kořenového oddílu"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "Využito"
@@ -1752,6 +1841,11 @@ msgstr "Využito"
msgid "Users"
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
msgid "Value"
msgstr "Hodnota"
@@ -1761,6 +1855,7 @@ msgid "View"
msgstr "Zobrazení"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Zobrazit více"
@@ -1773,7 +1868,7 @@ msgstr "Zobrazit vašich 200 nejnovějších upozornění."
msgid "Visible Fields"
msgstr "Viditelné sloupce"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "Čeká se na dostatek záznamů k zobrazení"
@@ -1812,8 +1907,10 @@ msgid "Windows command"
msgstr "Windows příkaz"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "Psát"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: da\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-04-05 18:27\n"
"Last-Translator: \n"
"Language-Team: Danish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -18,6 +18,12 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "{0} tilgængelig"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -49,7 +55,7 @@ msgid "1 hour"
msgstr "1 time"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 minut"
@@ -66,7 +72,7 @@ msgid "12 hours"
msgstr "12 timer"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 minutter"
@@ -79,7 +85,7 @@ msgid "30 days"
msgstr "30 dage"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 minutter"
@@ -107,19 +113,14 @@ msgid "Active state"
msgstr "Aktiv tilstand"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "Tilføj {foo}"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "Tilføj <0>System</0>"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "Tilføj system"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Tilføj URL"
@@ -134,6 +135,7 @@ msgstr "Juster bredden af hovedlayoutet"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "Administrator"
@@ -147,7 +149,7 @@ msgstr "Efter indstilling af miljøvariablerne skal du genstarte din Beszel-hub
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -163,6 +165,7 @@ msgstr "Alarmer"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Alle containere"
@@ -188,11 +191,11 @@ msgstr "Er du sikker?"
msgid "Automatic copy requires a secure context."
msgstr "Automatisk kopiering kræver en sikker kontekst."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "Gennemsnitlig"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "Gennemsnitlig CPU udnyttelse af containere"
@@ -206,20 +209,29 @@ msgstr "Gennemsnit falder under <0>{value}{0}</0>"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Gennemsnittet overstiger <0>{value}{0}</0>"
#: src/components/routes/system.tsx
#: 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
msgid "Average power consumption of GPUs"
msgstr "Gennemsnitligt strømforbrug for GPU'er"
#: src/components/routes/system.tsx
#: 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
msgid "Average system-wide CPU utilization"
msgstr "Gennemsnitlig systembaseret CPU-udnyttelse"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "Gennemsnitlig udnyttelse af {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "Gennemsnitlig udnyttelse af GPU-enheder"
@@ -228,7 +240,7 @@ msgstr "Gennemsnitlig udnyttelse af GPU-enheder"
msgid "Backups"
msgstr "Sikkerhedskopier"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "Båndbredde"
@@ -236,9 +248,9 @@ msgstr "Båndbredde"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr "Bat"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "Batteri"
@@ -277,7 +289,7 @@ msgstr "Binær"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "Bits (Kbps, Mbps, Gbps)"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Boot state"
@@ -286,9 +298,9 @@ msgstr "Opstartstilstand"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "Bytes (KB/s, MB/s, GB/s)"
msgstr ""
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "Cache / Buffere"
@@ -324,7 +336,7 @@ msgstr "Forsigtig - muligt tab af data"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -334,7 +346,7 @@ msgstr "Ændre viste enheder for målinger."
msgid "Change general application options."
msgstr "Skift generelle applikationsindstillinger."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "Opladning"
@@ -347,6 +359,10 @@ msgstr "Oplader"
msgid "Chart options"
msgstr "Diagrammuligheder"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "Grafbredde"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "Tjek {email} for et nulstillingslink."
@@ -407,6 +423,10 @@ msgstr "Konflikter"
msgid "Connection is down"
msgstr "Forbindelsen er nede"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "Containere"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -433,6 +453,11 @@ msgctxt "Environment variables"
msgid "Copy env"
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
msgid "Copy host"
msgstr "Kopier vært"
@@ -462,11 +487,16 @@ msgstr "Kopier <0>docker-compose.yml</0> indholdet for agenten nedenfor, eller r
msgid "Copy YAML"
msgstr "Kopier YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "Kerne"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -474,7 +504,7 @@ msgstr "CPU-kerner"
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "CPU Peak"
msgstr "CPU Peak"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "CPU time"
@@ -484,8 +514,8 @@ msgstr "CPU tid"
msgid "CPU Time Breakdown"
msgstr "CPU-tidsfordeling"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -517,7 +547,7 @@ msgid "Cumulative Upload"
msgstr "Kumulativ upload"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "Nuværende tilstand"
@@ -531,6 +561,11 @@ msgstr "Cykler"
msgid "Daily"
msgstr "Dagligt"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "Standard"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "Standard tidsperiode"
@@ -563,37 +598,43 @@ msgstr "Enhed"
msgid "Discharging"
msgstr "Aflader"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disk"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "Disk I/O"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Disk unit"
msgstr "Diskenhed"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "Diskforbrug"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "Diskforbrug af {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "Visning"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "Docker CPU forbrug"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "Docker Hukommelsesforbrug"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "Docker Netværk I/O"
@@ -636,7 +677,7 @@ msgstr "Rediger {foo}"
#: src/components/login/forgot-pass-form.tsx
#: src/components/login/otp-forms.tsx
msgid "Email"
msgstr "Email"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Email notifications"
@@ -731,7 +772,7 @@ msgstr "Eksporter din nuværende systemkonfiguration."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -770,12 +811,12 @@ msgstr "Mislykkedes: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint"
@@ -783,7 +824,7 @@ msgstr "Fingeraftryk"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -800,6 +841,7 @@ msgid "FreeBSD command"
msgstr "FreeBSD kommando"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "Fuldt opladt"
@@ -812,13 +854,17 @@ msgstr "Generelt"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr "Global"
msgstr ""
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "GPU-enheder"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "GPU Strøm Træk"
@@ -826,6 +872,7 @@ msgstr "GPU Strøm Træk"
msgid "GPU Usage"
msgstr "GPU-forbrug"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "Gitter"
@@ -836,7 +883,7 @@ msgstr "Sundhed"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr "Hjerteslag"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -864,6 +911,21 @@ msgstr "HTTP-metode"
msgid "HTTP method: POST, GET, or HEAD (default: 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
#: src/lib/i18n.ts
msgid "Idle"
@@ -884,7 +946,7 @@ msgstr "Inaktiv"
#: src/components/routes/settings/heartbeat.tsx
msgid "Interval"
msgstr "Interval"
msgstr ""
#: src/components/login/auth-form.tsx
msgid "Invalid email address."
@@ -912,7 +974,7 @@ msgstr "Livscyklus"
msgid "limit"
msgstr "grænse"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "Belastning Gennemsnitlig"
@@ -941,6 +1003,7 @@ msgstr "Indlæsningstilstand"
msgid "Loading..."
msgstr "Indlæser..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Log ud"
@@ -958,7 +1021,7 @@ msgstr "Loginforsøg mislykkedes"
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
msgid "Logs"
msgstr "Logs"
msgstr ""
#: src/components/routes/settings/notifications.tsx
msgid "Looking instead for where to create alerts? Click the bell <0/> icons in the systems table."
@@ -978,7 +1041,7 @@ msgid "Manual setup instructions"
msgstr "Manuel opsætningsvejledning"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "Maks. 1 min"
@@ -999,18 +1062,19 @@ msgstr "Hukommelsesgrænse"
msgid "Memory Peak"
msgstr "Hukommelsesspids"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "Hukommelsesforbrug"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "Hukommelsesforbrug af dockercontainere"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
msgstr ""
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
@@ -1023,13 +1087,13 @@ msgstr "Navn"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Net"
msgstr "Net"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "Netværkstrafik af dockercontainere"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1152,7 +1216,7 @@ msgstr "Tidligere"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Pause"
msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Paused"
@@ -1171,13 +1235,17 @@ msgstr "Payload-format"
msgid "Per-core average utilization"
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
msgid "Percentage of time spent in each state"
msgstr "Procentdel af tid brugt i hver tilstand"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent"
msgstr "Permanent"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence"
@@ -1218,15 +1286,20 @@ msgstr "Log venligst ind på din konto"
#: src/components/add-system.tsx
msgid "Port"
msgstr "Port"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr "Porte"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "Tænd"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "Præcis udnyttelse på det registrerede tidspunkt"
@@ -1243,17 +1316,24 @@ msgstr "Proces startet"
msgid "Public Key"
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
msgid "Quiet Hours"
msgstr "Stille timer"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "Læs"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "Modtaget"
@@ -1304,7 +1384,7 @@ msgstr "Genoptag"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr "Root"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1326,6 +1406,10 @@ msgstr "S.M.A.R.T.-detaljer"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. selvtest"
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "Gem {foo}"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Gem adresse ved hjælp af enter eller komma. Lad feltet stå tomt for at deaktivere e-mail-meddelelser."
@@ -1335,10 +1419,6 @@ msgstr "Gem adresse ved hjælp af enter eller komma. Lad feltet stå tomt for at
msgid "Save Settings"
msgstr "Gem indstillinger"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "Gem system"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Gemt i databasen og udløber ikke, før du deaktiverer det."
@@ -1387,7 +1467,7 @@ msgstr "Send periodiske udgående pings til en ekstern overvågningstjeneste, s
msgid "Send test heartbeat"
msgstr "Send test-heartbeat"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "Sendt"
@@ -1399,6 +1479,7 @@ msgstr "Serienummer"
msgid "Service Details"
msgstr "Tjenestedetaljer"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "Tjenester"
@@ -1414,8 +1495,10 @@ msgstr "Indstil følgende miljøvariabler på din Beszel-hub for at aktivere hea
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "Indstillinger"
@@ -1453,23 +1536,24 @@ msgstr "Tilstand"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr "Status"
msgstr ""
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State"
msgstr "Undertilstand"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "Swap plads brugt af systemet"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "Swap forbrug"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1479,15 +1563,15 @@ msgstr "Swap forbrug"
#: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts
msgid "System"
msgstr "System"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "Gennemsnitlig system belastning over tid"
#: src/components/systemd-table/systemd-table.tsx
msgid "Systemd Services"
msgstr "Systemd Services"
msgstr ""
#: src/components/navbar.tsx
msgid "Systems"
@@ -1501,6 +1585,11 @@ msgstr "Systemer kan være administreres i filen <0>config.yml</0> i din datamap
msgid "Table"
msgstr "Tabel"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Faner"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Opgaver"
@@ -1511,7 +1600,7 @@ msgstr "Opgaver"
msgid "Temp"
msgstr "Temperatur"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "Temperatur"
@@ -1520,13 +1609,13 @@ msgstr "Temperatur"
msgid "Temperature unit"
msgstr "Temperaturenhed"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "Temperaturer i systemsensorer"
#: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
@@ -1552,11 +1641,11 @@ msgstr "Denne handling kan ikke fortrydes. Dette vil permanent slette alle aktue
msgid "This will permanently delete all selected records from the database."
msgstr "Dette vil permanent slette alle poster fra databasen."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "Gennemløb af {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "Gennemløb af rodfilsystemet"
@@ -1568,11 +1657,6 @@ msgstr "Tidsformat"
msgid "To email(s)"
msgstr "Til email(s)"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "Slå gitter til/fra"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1610,6 +1694,11 @@ msgstr "Samlet modtaget data for hver interface"
msgid "Total data sent for each 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
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
@@ -1671,7 +1760,7 @@ msgstr "Udløser når brugen af en disk overstiger en tærskel"
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/system/smart-table.tsx
msgid "Type"
msgstr "Type"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Unit file"
@@ -1730,20 +1819,20 @@ msgstr "Overfør"
msgid "Uptime"
msgstr "Oppetid"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "Forbrug"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "Brug af rodpartition"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "Brugt"
@@ -1752,6 +1841,11 @@ msgstr "Brugt"
msgid "Users"
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
msgid "Value"
msgstr "Værdi"
@@ -1761,6 +1855,7 @@ msgid "View"
msgstr "Vis"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Se mere"
@@ -1773,7 +1868,7 @@ msgstr "Se dine 200 nyeste alarmer."
msgid "Visible Fields"
msgstr "Synlige felter"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "Venter på nok posteringer til at vise"
@@ -1812,8 +1907,10 @@ msgid "Windows command"
msgstr "Windows-kommando"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "Skriv"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: de\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-04-05 18:27\n"
"Last-Translator: \n"
"Language-Team: German\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -18,6 +18,12 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "{0} verfügbar"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -49,7 +55,7 @@ msgid "1 hour"
msgstr "1 Stunde"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 Min"
@@ -66,7 +72,7 @@ msgid "12 hours"
msgstr "12 Stunden"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 Min"
@@ -79,7 +85,7 @@ msgid "30 days"
msgstr "30 Tage"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 Min"
@@ -107,19 +113,14 @@ msgid "Active state"
msgstr "Aktiver Zustand"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "{foo} hinzufügen"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "<0>System</0> hinzufügen"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "System hinzufügen"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "URL hinzufügen"
@@ -134,8 +135,9 @@ msgstr "Breite des Hauptlayouts anpassen"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "Admin"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "After"
@@ -147,7 +149,7 @@ msgstr "Starten Sie nach dem Festlegen der Umgebungsvariablen Ihren Beszel-Hub n
#: src/components/systems-table/systems-table-columns.tsx
msgid "Agent"
msgstr "Agent"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -163,6 +165,7 @@ msgstr "Warnungen"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Alle Container"
@@ -188,11 +191,11 @@ msgstr "Bist du sicher?"
msgid "Automatic copy requires a secure context."
msgstr "Automatisches Kopieren erfordert einen sicheren Kontext."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "Durchschnitt"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "Durchschnittliche CPU-Auslastung der Container"
@@ -206,29 +209,38 @@ msgstr "Durchschnitt unterschreitet <0>{value}{0}</0>"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Durchschnitt überschreitet <0>{value}{0}</0>"
#: src/components/routes/system.tsx
#: 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
msgid "Average power consumption of GPUs"
msgstr "Durchschnittlicher Stromverbrauch der GPUs"
#: src/components/routes/system.tsx
#: 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
msgid "Average system-wide CPU utilization"
msgstr "Durchschnittliche systemweite CPU-Auslastung"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "Durchschnittliche Auslastung von {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "Durchschnittliche Auslastung der GPU-Engines"
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Backups"
msgstr "Backups"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "Bandbreite"
@@ -236,9 +248,9 @@ msgstr "Bandbreite"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr "Bat"
msgstr "Batt"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "Batterie"
@@ -277,7 +289,7 @@ msgstr "Binär"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bits (Kbps, Mbps, Gbps)"
msgstr "Bits (Kbps, Mbps, Gbps)"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Boot state"
@@ -286,9 +298,9 @@ msgstr "Boot-Zustand"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/general.tsx
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "Bytes (KB/s, MB/s, GB/s)"
msgstr ""
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "Cache / Puffer"
@@ -324,7 +336,7 @@ msgstr "Vorsicht - potenzieller Datenverlust"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -334,7 +346,7 @@ msgstr "Anzeigeeinheiten der Werte ändern."
msgid "Change general application options."
msgstr "Allgemeine Anwendungsoptionen ändern."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "Ladung"
@@ -347,6 +359,10 @@ msgstr "Wird geladen"
msgid "Chart options"
msgstr "Diagrammoptionen"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "Diagrammbreite"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "Überprüfe {email} auf einen Link zum Zurücksetzen."
@@ -407,6 +423,10 @@ msgstr "Konflikte"
msgid "Connection is down"
msgstr "Verbindung unterbrochen"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "Container"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -433,6 +453,11 @@ msgctxt "Environment variables"
msgid "Copy env"
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
msgid "Copy host"
msgstr "Host kopieren"
@@ -462,11 +487,16 @@ msgstr "Kopiere den<0>docker-compose.yml</0> Inhalt für den Agent unten oder re
msgid "Copy YAML"
msgstr "YAML kopieren"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "Kern"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -484,8 +514,8 @@ msgstr "CPU-Zeit"
msgid "CPU Time Breakdown"
msgstr "CPU-Zeit-Aufschlüsselung"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -517,7 +547,7 @@ msgid "Cumulative Upload"
msgstr "Kumulativer Upload"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "Aktueller Zustand"
@@ -531,6 +561,11 @@ msgstr "Zyklen"
msgid "Daily"
msgstr "Täglich"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "Standard"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "Standardzeitraum"
@@ -563,11 +598,12 @@ msgstr "Gerät"
msgid "Discharging"
msgstr "Wird entladen"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Festplatte"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "Festplatten-I/O"
@@ -575,25 +611,30 @@ msgstr "Festplatten-I/O"
msgid "Disk unit"
msgstr "Festplatteneinheit"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "Festplattennutzung"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "Festplattennutzung von {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "Anzeige"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "Docker-CPU-Auslastung"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "Docker-Arbeitsspeichernutzung"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "Docker-Netzwerk-I/O"
@@ -731,7 +772,7 @@ msgstr "Exportiere die aktuelle Systemkonfiguration."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -770,12 +811,12 @@ msgstr "Fehlgeschlagen: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Filter..."
msgstr "Filter..."
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Fingerprint"
@@ -800,6 +841,7 @@ msgid "FreeBSD command"
msgstr "FreeBSD Befehl"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "Voll"
@@ -812,13 +854,17 @@ msgstr "Allgemein"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr "Global"
msgstr ""
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "GPU-Engines"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "GPU-Leistungsaufnahme"
@@ -826,6 +872,7 @@ msgstr "GPU-Leistungsaufnahme"
msgid "GPU Usage"
msgstr "GPU-Auslastung"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "Raster"
@@ -836,7 +883,7 @@ msgstr "Gesundheit"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -854,7 +901,7 @@ msgstr "Homebrew-Befehl"
#: src/components/add-system.tsx
msgid "Host / IP"
msgstr "Host / IP"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "HTTP Method"
@@ -864,6 +911,21 @@ msgstr "HTTP-Methode"
msgid "HTTP method: POST, GET, or HEAD (default: 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
#: src/lib/i18n.ts
msgid "Idle"
@@ -876,7 +938,7 @@ msgstr "Wenn du das Passwort für dein Administratorkonto verloren hast, kannst
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Docker image"
msgid "Image"
msgstr "Image"
msgstr ""
#: src/components/routes/settings/quiet-hours.tsx
msgid "Inactive"
@@ -912,7 +974,7 @@ msgstr "Lebenszyklus"
msgid "limit"
msgstr "Limit"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "Durchschnittliche Systemlast"
@@ -941,6 +1003,7 @@ msgstr "Ladezustand"
msgid "Loading..."
msgstr "Lädt..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Abmelden"
@@ -978,7 +1041,7 @@ msgid "Manual setup instructions"
msgstr "Anleitung zur manuellen Einrichtung"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "Max 1 Min"
@@ -999,15 +1062,16 @@ msgstr "Arbeitsspeicherlimit"
msgid "Memory Peak"
msgstr "Arbeitsspeicher-Spitze"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "Arbeitsspeichernutzung"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "Arbeitsspeichernutzung der Docker-Container"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Modell"
@@ -1018,18 +1082,18 @@ msgstr "Modell"
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Name"
msgstr "Name"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Net"
msgstr "Netzwerk"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "Netzwerkverkehr der Docker-Container"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1152,7 +1216,7 @@ msgstr "Vergangen"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Pause"
msgstr "Pause"
msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Paused"
@@ -1171,13 +1235,17 @@ msgstr "Payload-Format"
msgid "Per-core average utilization"
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
msgid "Percentage of time spent in each state"
msgstr "Prozentsatz der Zeit in jedem Zustand"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Permanent"
msgstr "Permanent"
msgstr "Dauerhaft"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Persistence"
@@ -1218,15 +1286,20 @@ msgstr "Bitte melde dich bei deinem Konto an"
#: src/components/add-system.tsx
msgid "Port"
msgstr "Port"
msgstr ""
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr ""
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "Eingeschaltet"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "Genaue Nutzung zum aufgezeichneten Zeitpunkt"
@@ -1243,17 +1316,24 @@ msgstr "Prozess gestartet"
msgid "Public Key"
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
msgid "Quiet Hours"
msgstr "Ruhezeiten"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "Lesen"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "Empfangen"
@@ -1304,7 +1384,7 @@ msgstr "Fortsetzen"
#: src/components/systems-table/systems-table-columns.tsx
msgctxt "Root disk label"
msgid "Root"
msgstr "Root"
msgstr ""
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Rotate token"
@@ -1326,6 +1406,10 @@ msgstr "S.M.A.R.T.-Details"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T.-Selbsttest"
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "{foo} speichern"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail-Benachrichtigungen zu deaktivieren."
@@ -1335,10 +1419,6 @@ msgstr "Adresse mit der Enter-Taste oder Komma speichern. Leer lassen, um E-Mail
msgid "Save Settings"
msgstr "Einstellungen speichern"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "System speichern"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "In der Datenbank gespeichert und läuft nicht ab, bis Sie es deaktivieren."
@@ -1387,7 +1467,7 @@ msgstr "Senden Sie regelmäßige ausgehende Pings an einen externen Überwachung
msgid "Send test heartbeat"
msgstr "Test-Heartbeat senden"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "Gesendet"
@@ -1399,6 +1479,7 @@ msgstr "Seriennummer"
msgid "Service Details"
msgstr "Servicedetails"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "Dienste"
@@ -1414,8 +1495,10 @@ msgstr "Legen Sie die folgenden Umgebungsvariablen auf Ihrem Beszel-Hub fest, um
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "Einstellungen"
@@ -1453,23 +1536,24 @@ msgstr "Status"
#: src/components/systems-table/systems-table.tsx
#: src/lib/alerts.ts
msgid "Status"
msgstr "Status"
msgstr ""
#: src/components/systemd-table/systemd-table-columns.tsx
msgid "Sub State"
msgstr "Unterzustand"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "Vom System genutzter Swap-Speicher"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "Swap-Nutzung"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1479,9 +1563,9 @@ msgstr "Swap-Nutzung"
#: src/components/systems-table/systems-table-columns.tsx
#: src/lib/alerts.ts
msgid "System"
msgstr "System"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "Systemlastdurchschnitt im Zeitverlauf"
@@ -1501,6 +1585,11 @@ msgstr "Systeme können in einer <0>config.yml</0>-Datei im Datenverzeichnis ver
msgid "Table"
msgstr "Tabelle"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Aufgaben"
@@ -1511,7 +1600,7 @@ msgstr "Aufgaben"
msgid "Temp"
msgstr "Temperatur"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "Temperatur"
@@ -1520,13 +1609,13 @@ msgstr "Temperatur"
msgid "Temperature unit"
msgstr "Temperatureinheit"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "Temperaturen der Systemsensoren"
#: src/components/routes/settings/notifications.tsx
msgid "Test <0>URL</0>"
msgstr "Test <0>URL</0>"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Test heartbeat"
@@ -1552,11 +1641,11 @@ msgstr "Diese Aktion kann nicht rückgängig gemacht werden. Dadurch werden alle
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."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "Durchsatz von {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "Durchsatz des Root-Dateisystems"
@@ -1568,11 +1657,6 @@ msgstr "Zeitformat"
msgid "To email(s)"
msgstr "An E-Mail(s)"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "Raster umschalten"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1581,7 +1665,7 @@ msgstr "Darstellung umschalten"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1610,6 +1694,11 @@ msgstr "Empfangene Gesamtdatenmenge je Schnittstelle "
msgid "Total data sent for each interface"
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
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
@@ -1730,20 +1819,20 @@ msgstr "Hochladen"
msgid "Uptime"
msgstr "Betriebszeit"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "Nutzung"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "Nutzung der Root-Partition"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "Verwendet"
@@ -1752,6 +1841,11 @@ msgstr "Verwendet"
msgid "Users"
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
msgid "Value"
msgstr "Wert"
@@ -1761,6 +1855,7 @@ msgid "View"
msgstr "Ansicht"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Mehr anzeigen"
@@ -1773,7 +1868,7 @@ msgstr "Sieh dir die neusten 200 Alarme an."
msgid "Visible Fields"
msgstr "Sichtbare Spalten"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "Warten auf genügend Datensätze zur Anzeige"
@@ -1812,8 +1907,10 @@ msgid "Windows command"
msgstr "Windows-Befehl"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "Schreiben"

View File

@@ -13,6 +13,12 @@ msgstr ""
"Language-Team: \n"
"Plural-Forms: \n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "{0} available"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -44,7 +50,7 @@ msgid "1 hour"
msgstr "1 hour"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 min"
@@ -61,7 +67,7 @@ msgid "12 hours"
msgstr "12 hours"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 min"
@@ -74,7 +80,7 @@ msgid "30 days"
msgstr "30 days"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 min"
@@ -102,19 +108,14 @@ msgid "Active state"
msgstr "Active state"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "Add {foo}"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "Add <0>System</0>"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "Add system"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Add URL"
@@ -129,6 +130,7 @@ msgstr "Adjust the width of the main layout"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "Admin"
@@ -158,6 +160,7 @@ msgstr "Alerts"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "All Containers"
@@ -183,11 +186,11 @@ msgstr "Are you sure?"
msgid "Automatic copy requires a secure context."
msgstr "Automatic copy requires a secure context."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "Average"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "Average CPU utilization of containers"
@@ -201,20 +204,29 @@ msgstr "Average drops below <0>{value}{0}</0>"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "Average exceeds <0>{value}{0}</0>"
#: src/components/routes/system.tsx
#: 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
msgid "Average power consumption of GPUs"
msgstr "Average power consumption of GPUs"
#: src/components/routes/system.tsx
#: 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
msgid "Average system-wide CPU utilization"
msgstr "Average system-wide CPU utilization"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "Average utilization of {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "Average utilization of GPU engines"
@@ -223,7 +235,7 @@ msgstr "Average utilization of GPU engines"
msgid "Backups"
msgstr "Backups"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "Bandwidth"
@@ -233,7 +245,7 @@ msgstr "Bandwidth"
msgid "Bat"
msgstr "Bat"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "Battery"
@@ -283,7 +295,7 @@ msgstr "Boot state"
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "Bytes (KB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "Cache / Buffers"
@@ -329,7 +341,7 @@ msgstr "Change display units for metrics."
msgid "Change general application options."
msgstr "Change general application options."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "Charge"
@@ -342,6 +354,10 @@ msgstr "Charging"
msgid "Chart options"
msgstr "Chart options"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "Chart width"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "Check {email} for a reset link."
@@ -402,6 +418,10 @@ msgstr "Conflicts"
msgid "Connection is down"
msgstr "Connection is down"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "Containers"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -428,6 +448,11 @@ msgctxt "Environment variables"
msgid "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
msgid "Copy host"
msgstr "Copy host"
@@ -457,6 +482,11 @@ msgstr "Copy the<0>docker-compose.yml</0> content for the agent below, or regist
msgid "Copy YAML"
msgstr "Copy YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "Core"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -479,8 +509,8 @@ msgstr "CPU time"
msgid "CPU Time Breakdown"
msgstr "CPU Time Breakdown"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -512,7 +542,7 @@ msgid "Cumulative Upload"
msgstr "Cumulative Upload"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "Current state"
@@ -526,6 +556,11 @@ msgstr "Cycles"
msgid "Daily"
msgstr "Daily"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "Default"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "Default time period"
@@ -558,11 +593,12 @@ msgstr "Device"
msgid "Discharging"
msgstr "Discharging"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disk"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "Disk I/O"
@@ -570,25 +606,30 @@ msgstr "Disk I/O"
msgid "Disk unit"
msgstr "Disk unit"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "Disk Usage"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "Disk usage of {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "Display"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "Docker CPU Usage"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "Docker Memory Usage"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "Docker Network I/O"
@@ -765,7 +806,7 @@ msgstr "Failed: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
@@ -795,6 +836,7 @@ msgid "FreeBSD command"
msgstr "FreeBSD command"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "Full"
@@ -810,10 +852,14 @@ msgid "Global"
msgstr "Global"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr "GPU"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "GPU Engines"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "GPU Power Draw"
@@ -821,6 +867,7 @@ msgstr "GPU Power Draw"
msgid "GPU Usage"
msgstr "GPU Usage"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "Grid"
@@ -859,6 +906,21 @@ msgstr "HTTP Method"
msgid "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
#: src/lib/i18n.ts
msgid "Idle"
@@ -907,7 +969,7 @@ msgstr "Lifecycle"
msgid "limit"
msgstr "limit"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "Load Average"
@@ -936,6 +998,7 @@ msgstr "Load state"
msgid "Loading..."
msgstr "Loading..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Log Out"
@@ -973,7 +1036,7 @@ msgid "Manual setup instructions"
msgstr "Manual setup instructions"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "Max 1 min"
@@ -994,15 +1057,16 @@ msgstr "Memory limit"
msgid "Memory Peak"
msgstr "Memory Peak"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "Memory Usage"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "Memory usage of docker containers"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Model"
@@ -1020,11 +1084,11 @@ msgstr "Name"
msgid "Net"
msgstr "Net"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "Network traffic of docker containers"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1166,6 +1230,10 @@ msgstr "Payload format"
msgid "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
msgid "Percentage of time spent in each state"
msgstr "Percentage of time spent in each state"
@@ -1215,13 +1283,18 @@ msgstr "Please sign in to your account"
msgid "Port"
msgstr "Port"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr "Ports"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "Power On"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "Precise utilization at the recorded time"
@@ -1238,17 +1311,24 @@ msgstr "Process started"
msgid "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
msgid "Quiet Hours"
msgstr "Quiet Hours"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "Read"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "Received"
@@ -1321,6 +1401,10 @@ msgstr "S.M.A.R.T. Details"
msgid "S.M.A.R.T. Self-Test"
msgstr "S.M.A.R.T. Self-Test"
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "Save {foo}"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Save address using enter key or comma. Leave blank to disable email notifications."
@@ -1330,10 +1414,6 @@ msgstr "Save address using enter key or comma. Leave blank to disable email noti
msgid "Save Settings"
msgstr "Save Settings"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "Save system"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Saved in the database and does not expire until you disable it."
@@ -1382,7 +1462,7 @@ msgstr "Send periodic outbound pings to an external monitoring service so you ca
msgid "Send test heartbeat"
msgstr "Send test heartbeat"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "Sent"
@@ -1394,6 +1474,7 @@ msgstr "Serial Number"
msgid "Service Details"
msgstr "Service Details"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "Services"
@@ -1409,8 +1490,10 @@ msgstr "Set the following environment variables on your Beszel hub to enable hea
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "Settings"
@@ -1454,17 +1537,18 @@ msgstr "Status"
msgid "Sub State"
msgstr "Sub State"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "Swap space used by the system"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "Swap Usage"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1476,7 +1560,7 @@ msgstr "Swap Usage"
msgid "System"
msgstr "System"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "System load averages over time"
@@ -1496,6 +1580,11 @@ msgstr "Systems may be managed in a <0>config.yml</0> file inside your data dire
msgid "Table"
msgstr "Table"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Tabs"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Tasks"
@@ -1506,7 +1595,7 @@ msgstr "Tasks"
msgid "Temp"
msgstr "Temp"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "Temperature"
@@ -1515,7 +1604,7 @@ msgstr "Temperature"
msgid "Temperature unit"
msgstr "Temperature unit"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "Temperatures of system sensors"
@@ -1547,11 +1636,11 @@ msgstr "This action cannot be undone. This will permanently delete all current r
msgid "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.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "Throughput of {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "Throughput of root filesystem"
@@ -1563,11 +1652,6 @@ msgstr "Time format"
msgid "To email(s)"
msgstr "To email(s)"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "Toggle grid"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1605,6 +1689,11 @@ msgstr "Total data received for each interface"
msgid "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
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
@@ -1725,20 +1814,20 @@ msgstr "Upload"
msgid "Uptime"
msgstr "Uptime"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "Usage"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "Usage of root partition"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "Used"
@@ -1747,6 +1836,11 @@ msgstr "Used"
msgid "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
msgid "Value"
msgstr "Value"
@@ -1756,6 +1850,7 @@ msgid "View"
msgstr "View"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "View more"
@@ -1768,7 +1863,7 @@ msgstr "View your 200 most recent alerts."
msgid "Visible Fields"
msgstr "Visible Fields"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "Waiting for enough records to display"
@@ -1807,8 +1902,10 @@ msgid "Windows command"
msgstr "Windows command"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "Write"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: es\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-04-05 18:27\n"
"Last-Translator: \n"
"Language-Team: Spanish\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -18,6 +18,12 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "{0} disponible"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -49,9 +55,9 @@ msgid "1 hour"
msgstr "1 hora"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "1 min"
msgstr ""
#: src/lib/utils.ts
msgid "1 minute"
@@ -66,9 +72,9 @@ msgid "12 hours"
msgstr "12 horas"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "15 min"
msgstr ""
#: src/lib/utils.ts
msgid "24 hours"
@@ -79,9 +85,9 @@ msgid "30 days"
msgstr "30 días"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "5 min"
msgstr ""
#. Table column
#: src/components/routes/settings/quiet-hours.tsx
@@ -107,19 +113,14 @@ msgid "Active state"
msgstr "Estado activo"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "Agregar {foo}"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "Agregar <0>sistema</0>"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "Agregar sistema"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "Agregar URL"
@@ -134,6 +135,7 @@ msgstr "Ajustar el ancho del diseño principal"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "Administrador"
@@ -163,6 +165,7 @@ msgstr "Alertas"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "Todos los contenedores"
@@ -188,11 +191,11 @@ msgstr "¿Estás seguro?"
msgid "Automatic copy requires a secure context."
msgstr "La copia automática requiere un contexto seguro."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "Promedio"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "Utilización promedio de CPU de los contenedores"
@@ -206,20 +209,29 @@ msgstr "El promedio cae por debajo de <0>{value}{0}</0>"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "El promedio excede <0>{value}{0}</0>"
#: src/components/routes/system.tsx
#: 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
msgid "Average power consumption of GPUs"
msgstr "Consumo de energía promedio de GPUs"
#: src/components/routes/system.tsx
#: 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
msgid "Average system-wide CPU utilization"
msgstr "Utilización promedio de CPU del sistema"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "Uso promedio de {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "Utilización promedio de motores GPU"
@@ -228,7 +240,7 @@ msgstr "Utilización promedio de motores GPU"
msgid "Backups"
msgstr "Copias de seguridad"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "Ancho de banda"
@@ -236,9 +248,9 @@ msgstr "Ancho de banda"
#. Battery label in systems table header
#: src/components/systems-table/systems-table-columns.tsx
msgid "Bat"
msgstr "Bat"
msgstr ""
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "Batería"
@@ -288,7 +300,7 @@ msgstr "Estado de arranque"
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "Bytes (kB/s, MB/s, GB/s)"
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "Caché / Buffers"
@@ -324,7 +336,7 @@ msgstr "Precaución - posible pérdida de datos"
#: src/components/routes/settings/general.tsx
msgid "Celsius (°C)"
msgstr "Celsius (°C)"
msgstr ""
#: src/components/routes/settings/general.tsx
msgid "Change display units for metrics."
@@ -334,7 +346,7 @@ msgstr "Cambiar las unidades de visualización de las métricas."
msgid "Change general application options."
msgstr "Cambiar las opciones generales de la aplicación."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "Carga"
@@ -347,6 +359,10 @@ msgstr "Cargando"
msgid "Chart options"
msgstr "Opciones de gráficos"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "Ancho del gráfico"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "Revisa {email} para un enlace de restablecimiento."
@@ -407,6 +423,10 @@ msgstr "Conflictos"
msgid "Connection is down"
msgstr "La conexión está caída"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "Contenedores"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -433,6 +453,11 @@ msgctxt "Environment variables"
msgid "Copy 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
msgid "Copy host"
msgstr "Copiar host"
@@ -462,11 +487,16 @@ msgstr "Copia el contenido del<0>docker-compose.yml</0> para el agente a continu
msgid "Copy YAML"
msgstr "Copiar YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "Núcleo"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "CPU"
msgstr "CPU"
msgstr ""
#: src/components/routes/system/cpu-sheet.tsx
msgid "CPU Cores"
@@ -484,8 +514,8 @@ msgstr "Tiempo de CPU"
msgid "CPU Time Breakdown"
msgstr "Desglose de tiempo de CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -517,7 +547,7 @@ msgid "Cumulative Upload"
msgstr "Carga acumulada"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "Estado actual"
@@ -531,6 +561,11 @@ msgstr "Ciclos"
msgid "Daily"
msgstr "Diariamente"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "Predeterminado"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "Periodo de tiempo predeterminado"
@@ -563,11 +598,12 @@ msgstr "Dispositivo"
msgid "Discharging"
msgstr "Descargando"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "Disco"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "E/S de Disco"
@@ -575,25 +611,30 @@ msgstr "E/S de Disco"
msgid "Disk unit"
msgstr "Unidad de disco"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "Uso de disco"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "Uso de disco de {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "Pantalla"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "Uso de CPU de Docker"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "Uso de memoria de Docker"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "E/S de red de Docker"
@@ -688,7 +729,7 @@ msgstr "Efímero"
#: src/components/routes/settings/tokens-fingerprints.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "Error"
msgstr "Error"
msgstr ""
#: src/components/routes/settings/heartbeat.tsx
msgid "Example:"
@@ -731,7 +772,7 @@ msgstr "Exporta la configuración actual de sus sistemas."
#: src/components/routes/settings/general.tsx
msgid "Fahrenheit (°F)"
msgstr "Fahrenheit (°F)"
msgstr ""
#: src/components/systems-table/systems-table-columns.tsx
msgid "Failed"
@@ -770,7 +811,7 @@ msgstr "Fallidos: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
@@ -783,7 +824,7 @@ msgstr "Huella dactilar"
#: src/components/routes/system/smart-table.tsx
msgid "Firmware"
msgstr "Firmware"
msgstr ""
#: src/components/alerts/alerts-sheet.tsx
msgid "For <0>{min}</0> {min, plural, one {minute} other {minutes}}"
@@ -800,6 +841,7 @@ msgid "FreeBSD command"
msgstr "Comando FreeBSD"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "Llena"
@@ -808,17 +850,21 @@ msgstr "Llena"
#: src/components/routes/settings/general.tsx
#: src/components/routes/settings/layout.tsx
msgid "General"
msgstr "General"
msgstr ""
#: src/components/routes/settings/quiet-hours.tsx
msgid "Global"
msgstr "Global"
msgstr ""
#: src/components/routes/system.tsx
msgid "GPU"
msgstr ""
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "Motores GPU"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "Consumo de energía de la GPU"
@@ -826,6 +872,7 @@ msgstr "Consumo de energía de la GPU"
msgid "GPU Usage"
msgstr "Uso de GPU"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "Cuadrícula"
@@ -836,7 +883,7 @@ msgstr "Estado"
#: src/components/routes/settings/layout.tsx
msgid "Heartbeat"
msgstr "Heartbeat"
msgstr "Latido"
#: src/components/routes/settings/heartbeat.tsx
msgid "Heartbeat Monitoring"
@@ -864,6 +911,21 @@ msgstr "Método HTTP"
msgid "HTTP method: POST, GET, or HEAD (default: 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
#: src/lib/i18n.ts
msgid "Idle"
@@ -912,7 +974,7 @@ msgstr "Ciclo de vida"
msgid "limit"
msgstr "límite"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "Carga media"
@@ -941,6 +1003,7 @@ msgstr "Estado de carga"
msgid "Loading..."
msgstr "Cargando..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "Cerrar sesión"
@@ -978,7 +1041,7 @@ msgid "Manual setup instructions"
msgstr "Instrucciones manuales de configuración"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "Máx. 1 min"
@@ -999,15 +1062,16 @@ msgstr "Límite de memoria"
msgid "Memory Peak"
msgstr "Pico de memoria"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "Uso de memoria"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "Uso de memoria de los contenedores Docker"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "Modelo"
@@ -1025,11 +1089,11 @@ msgstr "Nombre"
msgid "Net"
msgstr "Red"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "Tráfico de red de los contenedores Docker"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1045,7 +1109,7 @@ msgstr "Unidad de red"
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systemd-table/systemd-table.tsx
msgid "No"
msgstr "No"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/systemd-table/systemd-table.tsx
@@ -1171,6 +1235,10 @@ msgstr "Formato de carga útil (payload)"
msgid "Per-core average utilization"
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
msgid "Percentage of time spent in each state"
msgstr "Porcentaje de tiempo dedicado a cada estado"
@@ -1220,13 +1288,18 @@ msgstr "Por favor, inicia sesión en tu cuenta"
msgid "Port"
msgstr "Puerto"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr "Puertos"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "Encendido"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "Utilización precisa en el momento registrado"
@@ -1243,17 +1316,24 @@ msgstr "Proceso iniciado"
msgid "Public Key"
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
msgid "Quiet Hours"
msgstr "Horas de silencio"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "Lectura"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "Recibido"
@@ -1326,6 +1406,10 @@ msgstr "Detalles S.M.A.R.T."
msgid "S.M.A.R.T. Self-Test"
msgstr "Autoprueba S.M.A.R.T."
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "Guardar {foo}"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "Guarda la dirección usando la tecla enter o coma. Deja en blanco para desactivar las notificaciones por correo."
@@ -1335,10 +1419,6 @@ msgstr "Guarda la dirección usando la tecla enter o coma. Deja en blanco para d
msgid "Save Settings"
msgstr "Guardar configuración"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "Guardar sistema"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "Guardado en la base de datos y no expira hasta que lo desactives."
@@ -1387,7 +1467,7 @@ msgstr "Envíe pings salientes periódicos a un servicio de monitorización exte
msgid "Send test heartbeat"
msgstr "Enviar latido de prueba"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "Enviado"
@@ -1399,6 +1479,7 @@ msgstr "Número de serie"
msgid "Service Details"
msgstr "Detalles del servicio"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "Servicios"
@@ -1414,8 +1495,10 @@ msgstr "Configure las siguientes variables de entorno en su hub Beszel para habi
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "Configuración"
@@ -1459,17 +1542,18 @@ msgstr "Estado"
msgid "Sub State"
msgstr "Subestado"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "Espacio de swap utilizado por el sistema"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "Uso de swap"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1481,7 +1565,7 @@ msgstr "Uso de swap"
msgid "System"
msgstr "Sistema"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "Promedios de carga del sistema a lo largo del tiempo"
@@ -1501,6 +1585,11 @@ msgstr "Los sistemas pueden ser gestionados en un archivo <0>config.yml</0> dent
msgid "Table"
msgstr "Tabla"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "Pestañas"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "Tareas"
@@ -1511,7 +1600,7 @@ msgstr "Tareas"
msgid "Temp"
msgstr "Temperatura"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "Temperatura"
@@ -1520,7 +1609,7 @@ msgstr "Temperatura"
msgid "Temperature unit"
msgstr "Unidad de temperatura"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "Temperaturas de los sensores del sistema"
@@ -1552,11 +1641,11 @@ msgstr "Esta acción no se puede deshacer. Esto eliminará permanentemente todos
msgid "This will permanently delete all selected records from the database."
msgstr "Esto eliminará permanentemente todos los registros seleccionados de la base de datos."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "Rendimiento de {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "Rendimiento del sistema de archivos raíz"
@@ -1568,11 +1657,6 @@ msgstr "Formato de hora"
msgid "To email(s)"
msgstr "A correo(s)"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "Alternar cuadrícula"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1581,7 +1665,7 @@ msgstr "Alternar tema"
#: src/components/add-system.tsx
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Token"
msgstr "Token"
msgstr ""
#: src/components/command-palette.tsx
#: src/components/routes/settings/layout.tsx
@@ -1600,7 +1684,7 @@ msgstr "Los tokens y las huellas digitales se utilizan para autenticar las conex
#: src/components/ui/chart.tsx
#: src/components/ui/chart.tsx
msgid "Total"
msgstr "Total"
msgstr ""
#: src/components/routes/system/network-sheet.tsx
msgid "Total data received for each interface"
@@ -1610,10 +1694,15 @@ msgstr "Datos totales recibidos por cada interfaz"
msgid "Total data sent for each interface"
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
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
msgstr "Total: {0}"
msgstr ""
#: src/components/systemd-table/systemd-table.tsx
msgid "Triggered by"
@@ -1728,22 +1817,22 @@ msgstr "Cargar"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Uptime"
msgstr "Uptime"
msgstr "Tiempo de actividad"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "Uso"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "Uso de la partición raíz"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "Usado"
@@ -1752,6 +1841,11 @@ msgstr "Usado"
msgid "Users"
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
msgid "Value"
msgstr "Valor"
@@ -1761,6 +1855,7 @@ msgid "View"
msgstr "Vista"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "Ver más"
@@ -1773,7 +1868,7 @@ msgstr "Ver tus 200 alertas más recientes."
msgid "Visible Fields"
msgstr "Columnas visibles"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "Esperando suficientes registros para mostrar"
@@ -1812,8 +1907,10 @@ msgid "Windows command"
msgstr "Comando Windows"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "Escritura"

View File

@@ -8,7 +8,7 @@ msgstr ""
"Language: fa\n"
"Project-Id-Version: beszel\n"
"Report-Msgid-Bugs-To: \n"
"PO-Revision-Date: 2026-01-31 21:16\n"
"PO-Revision-Date: 2026-04-05 18:28\n"
"Last-Translator: \n"
"Language-Team: Persian\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
@@ -18,6 +18,12 @@ msgstr ""
"X-Crowdin-File: /main/internal/site/src/locales/en/en.po\n"
"X-Crowdin-File-ID: 32\n"
#. placeholder {0}: newVersion.v
#: src/components/footer-repo-link.tsx
msgctxt "New version available"
msgid "{0} available"
msgstr "{0} در دسترس است"
#. placeholder {0}: table.getFilteredSelectedRowModel().rows.length
#. placeholder {1}: table.getFilteredRowModel().rows.length
#: src/components/routes/settings/alerts-history-data-table.tsx
@@ -49,7 +55,7 @@ msgid "1 hour"
msgstr "۱ ساعت"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "1 min"
msgstr "۱ دقیقه"
@@ -66,7 +72,7 @@ msgid "12 hours"
msgstr "۱۲ ساعت"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "15 min"
msgstr "۱۵ دقیقه"
@@ -79,7 +85,7 @@ msgid "30 days"
msgstr "۳۰ روز"
#. Load average
#: src/components/charts/load-average-chart.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "5 min"
msgstr "۵ دقیقه"
@@ -107,19 +113,14 @@ msgid "Active state"
msgstr "وضعیت فعال"
#: src/components/add-system.tsx
#: src/components/add-system.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
msgid "Add {foo}"
msgstr "افزودن {foo}"
#: src/components/add-system.tsx
msgid "Add <0>System</0>"
msgstr "افزودن <0>سیستم</0>"
#: src/components/add-system.tsx
msgid "Add system"
msgstr "افزودن سیستم"
#: src/components/routes/settings/notifications.tsx
msgid "Add URL"
msgstr "افزودن آدرس اینترنتی"
@@ -134,6 +135,7 @@ msgstr "تنظیم عرض چیدمان اصلی"
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
msgid "Admin"
msgstr "مدیر"
@@ -163,6 +165,7 @@ msgstr "هشدارها"
#: src/components/command-palette.tsx
#: src/components/containers-table/containers-table.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/containers.tsx
msgid "All Containers"
msgstr "همه کانتینرها"
@@ -188,11 +191,11 @@ msgstr "آیا مطمئن هستید؟"
msgid "Automatic copy requires a secure context."
msgstr "کپی خودکار نیاز به یک زمینه امن دارد."
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Average"
msgstr "میانگین"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average CPU utilization of containers"
msgstr "میانگین استفاده از CPU کانتینرها"
@@ -206,20 +209,29 @@ msgstr "میانگین به زیر <0>{value}{0}</0> می‌افتد"
msgid "Average exceeds <0>{value}{0}</0>"
msgstr "میانگین از <0>{value}{0}</0> فراتر رفته است"
#: src/components/routes/system.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Average number of I/O operations waiting to be serviced"
msgstr "میانگین تعداد عملیات ورودی/خروجی در انتظار سرویس"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average power consumption of GPUs"
msgstr "میانگین مصرف برق پردازنده‌های گرافیکی"
#: src/components/routes/system.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average operation time (iostat await)"
msgid "Average queue to completion time per operation"
msgstr "میانگین زمان صف تا تکمیل به ازای هر عملیات"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Average system-wide CPU utilization"
msgstr "میانگین استفاده از CPU در کل سیستم"
#. placeholder {0}: gpu.n
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of {0}"
msgstr "میانگین استفاده از {0}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "Average utilization of GPU engines"
msgstr "میانگین استفاده از موتورهای GPU"
@@ -228,7 +240,7 @@ msgstr "میانگین استفاده از موتورهای GPU"
msgid "Backups"
msgstr "پشتیبان‌گیری‌ها"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/lib/alerts.ts
msgid "Bandwidth"
msgstr "پهنای باند"
@@ -238,7 +250,7 @@ msgstr "پهنای باند"
msgid "Bat"
msgstr "باتری"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Battery"
msgstr "باتری"
@@ -288,7 +300,7 @@ msgstr "وضعیت بوت"
msgid "Bytes (KB/s, MB/s, GB/s)"
msgstr "بایت (کیلوبایت بر ثانیه، مگابایت بر ثانیه، گیگابایت بر ثانیه)"
#: src/components/charts/mem-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Cache / Buffers"
msgstr "حافظه پنهان / بافرها"
@@ -334,7 +346,7 @@ msgstr "تغییر واحدهای نمایش برای معیارها."
msgid "Change general application options."
msgstr "تغییر گزینه‌های کلی برنامه."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Charge"
msgstr "شارژ"
@@ -347,6 +359,10 @@ msgstr "در حال شارژ"
msgid "Chart options"
msgstr "گزینه‌های نمودار"
#: src/components/routes/system/info-bar.tsx
msgid "Chart width"
msgstr "عرض نمودار"
#: src/components/login/forgot-pass-form.tsx
msgid "Check {email} for a reset link."
msgstr "ایمیل {email} خود را برای لینک بازنشانی بررسی کنید."
@@ -407,6 +423,10 @@ msgstr "تعارض‌ها"
msgid "Connection is down"
msgstr "اتصال قطع است"
#: src/components/routes/system.tsx
msgid "Containers"
msgstr "کانتینرها"
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Continue"
@@ -433,6 +453,11 @@ msgctxt "Environment variables"
msgid "Copy env"
msgstr "کپی متغیرهای محیط"
#: src/components/alerts/alerts-sheet.tsx
msgctxt "Copy alerts from another system"
msgid "Copy from"
msgstr "کپی از"
#: src/components/systems-table/systems-table-columns.tsx
msgid "Copy host"
msgstr "کپی میزبان"
@@ -462,6 +487,11 @@ msgstr "محتوای <0>docker-compose.yml</0> عامل زیر را کپی کن
msgid "Copy YAML"
msgstr "کپی YAML"
#: src/components/routes/system.tsx
msgctxt "Core system metrics"
msgid "Core"
msgstr "هسته"
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/systemd-table/systemd-table-columns.tsx
#: src/components/systems-table/systems-table-columns.tsx
@@ -484,8 +514,8 @@ msgstr "زمان CPU"
msgid "CPU Time Breakdown"
msgstr "تجزیه زمان CPU"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/charts/cpu-charts.tsx
#: src/components/routes/system/cpu-sheet.tsx
#: src/lib/alerts.ts
msgid "CPU Usage"
@@ -517,7 +547,7 @@ msgid "Cumulative Upload"
msgstr "آپلود تجمعی"
#. Context: Battery state
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Current state"
msgstr "وضعیت فعلی"
@@ -531,6 +561,11 @@ msgstr "چرخه‌ها"
msgid "Daily"
msgstr "روزانه"
#: src/components/routes/system/info-bar.tsx
msgctxt "Default system layout option"
msgid "Default"
msgstr "پیش‌فرض"
#: src/components/routes/settings/general.tsx
msgid "Default time period"
msgstr "بازه زمانی پیش‌فرض"
@@ -563,11 +598,12 @@ msgstr "دستگاه"
msgid "Discharging"
msgstr "در حال تخلیه"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Disk"
msgstr "دیسک"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk I/O"
msgstr "ورودی/خروجی دیسک"
@@ -575,25 +611,30 @@ msgstr "ورودی/خروجی دیسک"
msgid "Disk unit"
msgstr "واحد دیسک"
#: src/components/charts/disk-chart.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/lib/alerts.ts
msgid "Disk Usage"
msgstr "میزان استفاده از دیسک"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Disk usage of {extraFsName}"
msgstr "میزان استفاده از دیسک {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/info-bar.tsx
msgctxt "Layout display options"
msgid "Display"
msgstr "نمایش"
#: src/components/routes/system/charts/cpu-charts.tsx
msgid "Docker CPU Usage"
msgstr "میزان استفاده از CPU داکر"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Docker Memory Usage"
msgstr "میزان استفاده از حافظه داکر"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Docker Network I/O"
msgstr "ورودی/خروجی شبکه داکر"
@@ -770,7 +811,7 @@ msgstr "ناموفق: {0}"
#: src/components/containers-table/containers-table.tsx
#: src/components/routes/settings/alerts-history-data-table.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
#: src/components/routes/system/smart-table.tsx
#: src/components/systemd-table/systemd-table.tsx
#: src/components/systems-table/systems-table.tsx
@@ -800,6 +841,7 @@ msgid "FreeBSD command"
msgstr "دستور FreeBSD"
#. Context: Battery state
#: src/components/routes/system/info-bar.tsx
#: src/lib/i18n.ts
msgid "Full"
msgstr "پر"
@@ -815,10 +857,14 @@ msgid "Global"
msgstr "جهانی"
#: src/components/routes/system.tsx
msgid "GPU"
msgstr "پردازنده گرافیکی"
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Engines"
msgstr "موتورهای GPU"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
msgid "GPU Power Draw"
msgstr "مصرف برق پردازنده گرافیکی"
@@ -826,6 +872,7 @@ msgstr "مصرف برق پردازنده گرافیکی"
msgid "GPU Usage"
msgstr "میزان استفاده از GPU"
#: src/components/routes/system/info-bar.tsx
#: src/components/systems-table/systems-table.tsx
msgid "Grid"
msgstr "جدول"
@@ -864,6 +911,21 @@ msgstr "متد HTTP"
msgid "HTTP method: POST, GET, or HEAD (default: POST)"
msgstr "متد HTTP: POST، GET، یا HEAD (پیش‌فرض: POST)"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average operation time (iostat await)"
msgid "I/O Await"
msgstr "انتظار ورودی/خروجی"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O total time spent on read/write"
msgid "I/O Time"
msgstr "زمان ورودی/خروجی"
#: src/components/routes/system/charts/disk-charts.tsx
msgctxt "Percent of time the disk is busy with I/O"
msgid "I/O Utilization"
msgstr "استفاده از ورودی/خروجی"
#. Context: Battery state
#: src/lib/i18n.ts
msgid "Idle"
@@ -912,7 +974,7 @@ msgstr "چرخه حیات"
msgid "limit"
msgstr "محدودیت"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "Load Average"
msgstr "میانگین بار"
@@ -941,6 +1003,7 @@ msgstr "وضعیت بارگذاری"
msgid "Loading..."
msgstr "در حال بارگذاری..."
#: src/components/navbar.tsx
#: src/components/navbar.tsx
msgid "Log Out"
msgstr "خروج"
@@ -978,7 +1041,7 @@ msgid "Manual setup instructions"
msgstr "دستورالعمل‌های راه‌اندازی دستی"
#. Chart select field. Please try to keep this short.
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Max 1 min"
msgstr "حداکثر ۱ دقیقه"
@@ -999,15 +1062,16 @@ msgstr "محدودیت حافظه"
msgid "Memory Peak"
msgstr "حداکثر حافظه"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/lib/alerts.ts
msgid "Memory Usage"
msgstr "میزان استفاده از حافظه"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Memory usage of docker containers"
msgstr "میزان استفاده از حافظه کانتینرهای داکر"
#. Device model
#: src/components/routes/system/smart-table.tsx
msgid "Model"
msgstr "مدل"
@@ -1025,11 +1089,11 @@ msgstr "نام"
msgid "Net"
msgstr "شبکه"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Network traffic of docker containers"
msgstr "ترافیک شبکه کانتینرهای داکر"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
@@ -1171,6 +1235,10 @@ msgstr "فرمت پی‌لود"
msgid "Per-core average utilization"
msgstr "میانگین استفاده در هر هسته"
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Percent of time the disk is busy with I/O"
msgstr "درصد زمانی که دیسک مشغول عملیات ورودی/خروجی است"
#: src/components/routes/system/cpu-sheet.tsx
msgid "Percentage of time spent in each state"
msgstr "درصد زمان صرف شده در هر حالت"
@@ -1220,13 +1288,18 @@ msgstr "لطفاً به حساب کاربری خود وارد شوید"
msgid "Port"
msgstr "پورت"
#: src/components/containers-table/containers-table-columns.tsx
msgctxt "Container ports"
msgid "Ports"
msgstr "پورت‌ها"
#. Power On Time
#: src/components/routes/system/smart-table.tsx
msgid "Power On"
msgstr "روشن کردن"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/gpu-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Precise utilization at the recorded time"
msgstr "میزان دقیق استفاده در زمان ثبت شده"
@@ -1243,17 +1316,24 @@ msgstr "فرآیند شروع شد"
msgid "Public Key"
msgstr "کلید عمومی"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O average queue depth"
msgid "Queue Depth"
msgstr "عمق صف"
#: src/components/routes/settings/quiet-hours.tsx
msgid "Quiet Hours"
msgstr "ساعات آرام"
#. Disk read
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Read"
msgstr "خواندن"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Received"
msgstr "دریافت شد"
@@ -1326,6 +1406,10 @@ msgstr "جزئیات S.M.A.R.T"
msgid "S.M.A.R.T. Self-Test"
msgstr "تست خود S.M.A.R.T"
#: src/components/add-system.tsx
msgid "Save {foo}"
msgstr "ذخیره {foo}"
#: src/components/routes/settings/notifications.tsx
msgid "Save address using enter key or comma. Leave blank to disable email notifications."
msgstr "آدرس را با استفاده از کلید Enter یا کاما ذخیره کنید. برای غیرفعال کردن اعلان‌های ایمیلی، خالی بگذارید."
@@ -1335,10 +1419,6 @@ msgstr "آدرس را با استفاده از کلید Enter یا کاما ذخ
msgid "Save Settings"
msgstr "ذخیره تنظیمات"
#: src/components/add-system.tsx
msgid "Save system"
msgstr "ذخیره سیستم"
#: src/components/routes/settings/tokens-fingerprints.tsx
msgid "Saved in the database and does not expire until you disable it."
msgstr "در پایگاه داده ذخیره شده و تا زمانی که آن را غیرفعال نکنید، منقضی نمی‌شود."
@@ -1387,7 +1467,7 @@ msgstr "پینگ‌های خروجی دوره‌ای را به یک سرویس
msgid "Send test heartbeat"
msgstr "ارسال ضربان قلب آزمایشی"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/network-charts.tsx
msgid "Sent"
msgstr "ارسال شد"
@@ -1399,6 +1479,7 @@ msgstr "شماره سریال"
msgid "Service Details"
msgstr "جزئیات سرویس"
#: src/components/routes/system.tsx
#: src/components/systems-table/systems-table-columns.tsx
msgid "Services"
msgstr "سرویس‌ها"
@@ -1414,8 +1495,10 @@ msgstr "متغیرهای محیطی زیر را در هاب Beszel خود تنظ
#: src/components/command-palette.tsx
#: src/components/command-palette.tsx
#: src/components/navbar.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/settings/layout.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Settings"
msgstr "تنظیمات"
@@ -1459,17 +1542,18 @@ msgstr "وضعیت"
msgid "Sub State"
msgstr "وضعیت فرعی"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap space used by the system"
msgstr "فضای Swap استفاده شده توسط سیستم"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Swap Usage"
msgstr "میزان استفاده از Swap"
#: src/components/add-system.tsx
#: src/components/alerts-history-columns.tsx
#: src/components/containers-table/containers-table-columns.tsx
#: src/components/navbar.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
#: src/components/routes/settings/quiet-hours.tsx
@@ -1481,7 +1565,7 @@ msgstr "میزان استفاده از Swap"
msgid "System"
msgstr "سیستم"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/load-average-chart.tsx
msgid "System load averages over time"
msgstr "میانگین بار سیستم در طول زمان"
@@ -1501,6 +1585,11 @@ msgstr "سیستم‌ها ممکن است در یک فایل <0>config.yml</0>
msgid "Table"
msgstr "جدول"
#: src/components/routes/system/info-bar.tsx
msgctxt "Tabs system layout option"
msgid "Tabs"
msgstr "تب‌ها"
#: src/components/systemd-table/systemd-table.tsx
msgid "Tasks"
msgstr "وظایف"
@@ -1511,7 +1600,7 @@ msgstr "وظایف"
msgid "Temp"
msgstr "دما"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
#: src/lib/alerts.ts
msgid "Temperature"
msgstr "دما"
@@ -1520,7 +1609,7 @@ msgstr "دما"
msgid "Temperature unit"
msgstr "واحد دما"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/sensor-charts.tsx
msgid "Temperatures of system sensors"
msgstr "دمای حسگرهای سیستم"
@@ -1552,11 +1641,11 @@ msgstr "این عمل قابل برگشت نیست. این کار تمام رک
msgid "This will permanently delete all selected records from the database."
msgstr "این کار تمام رکوردهای انتخاب شده را برای همیشه از پایگاه داده حذف خواهد کرد."
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of {extraFsName}"
msgstr "توان عملیاتی {extraFsName}"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Throughput of root filesystem"
msgstr "توان عملیاتی سیستم فایل ریشه"
@@ -1568,11 +1657,6 @@ msgstr "فرمت زمان"
msgid "To email(s)"
msgstr "به ایمیل(ها)"
#: src/components/routes/system/info-bar.tsx
#: src/components/routes/system/info-bar.tsx
msgid "Toggle grid"
msgstr "تغییر نمایش جدول"
#: src/components/mode-toggle.tsx
#: src/components/mode-toggle.tsx
msgid "Toggle theme"
@@ -1610,6 +1694,11 @@ msgstr "داده‌های کل دریافت شده برای هر رابط"
msgid "Total data sent for each interface"
msgstr "داده‌های کل ارسال شده برای هر رابط"
#: src/components/routes/system/disk-io-sheet.tsx
msgctxt "Disk I/O"
msgid "Total time spent on read/write (can exceed 100%)"
msgstr ""
#. placeholder {0}: data.length
#: src/components/systemd-table/systemd-table.tsx
msgid "Total: {0}"
@@ -1730,20 +1819,20 @@ msgstr "آپلود"
msgid "Uptime"
msgstr "آپتایم"
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system.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/cpu-sheet.tsx
msgid "Usage"
msgstr "میزان استفاده"
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
msgid "Usage of root partition"
msgstr "میزان استفاده از پارتیشن ریشه"
#: src/components/charts/mem-chart.tsx
#: src/components/charts/swap-chart.tsx
#: src/components/routes/system/charts/memory-charts.tsx
#: src/components/routes/system/charts/memory-charts.tsx
msgid "Used"
msgstr "استفاده شده"
@@ -1752,6 +1841,11 @@ msgstr "استفاده شده"
msgid "Users"
msgstr "کاربران"
#: src/components/routes/system/charts/disk-charts.tsx
msgctxt "Disk I/O utilization"
msgid "Utilization"
msgstr "بهره‌وری"
#: src/components/alerts-history-columns.tsx
msgid "Value"
msgstr "مقدار"
@@ -1761,6 +1855,7 @@ msgid "View"
msgstr "مشاهده"
#: src/components/routes/system/cpu-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/network-sheet.tsx
msgid "View more"
msgstr "مشاهده بیشتر"
@@ -1773,7 +1868,7 @@ msgstr "۲۰۰ هشدار اخیر خود را مشاهده کنید."
msgid "Visible Fields"
msgstr "فیلدهای قابل مشاهده"
#: src/components/routes/system.tsx
#: src/components/routes/system/chart-card.tsx
msgid "Waiting for enough records to display"
msgstr "در انتظار رکوردهای کافی برای نمایش"
@@ -1812,8 +1907,10 @@ msgid "Windows command"
msgstr "دستور Windows"
#. Disk write
#: src/components/routes/system.tsx
#: src/components/routes/system.tsx
#: src/components/routes/system/charts/disk-charts.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
#: src/components/routes/system/disk-io-sheet.tsx
msgid "Write"
msgstr "نوشتن"

Some files were not shown because too many files have changed in this diff Show More